diff --git a/API.md b/API.md index 531bb6297..2fb3e549a 100644 --- a/API.md +++ b/API.md @@ -113,7 +113,8 @@ Output is the raw docker log. "slug": "SLUG", "date": "ISO", "name": "Custom name", - "type": "full|partial" + "type": "full|partial", + "protected": "bool" } ] } @@ -121,11 +122,14 @@ Output is the raw docker log. - POST `/snapshots/reload` +- POST `/snapshots/new/upload` + - POST `/snapshots/new/full` ```json { - "name": "Optional" + "name": "Optional", + "password": "Optional" } ``` @@ -135,7 +139,8 @@ Output is the raw docker log. { "name": "Optional", "addons": ["ADDON_SLUG"], - "folders": ["FOLDER_NAME"] + "folders": ["FOLDER_NAME"], + "password": "Optional" } ``` @@ -150,12 +155,14 @@ Output is the raw docker log. "name": "custom snapshot name / description", "date": "ISO", "size": "SIZE_IN_MB", + "protected": "bool", "homeassistant": "version", "addons": [ { "slug": "ADDON_SLUG", "name": "NAME", - "version": "INSTALLED_VERSION" + "version": "INSTALLED_VERSION", + "size": "SIZE_IN_MB" } ], "repositories": ["URL"], @@ -164,14 +171,25 @@ Output is the raw docker log. ``` - POST `/snapshots/{slug}/remove` + +- GET `/snapshots/{slug}/download` + - POST `/snapshots/{slug}/restore/full` + +```json +{ + "password": "Optional" +} +``` + - POST `/snapshots/{slug}/restore/partial` ```json { "homeassistant": "bool", "addons": ["ADDON_SLUG"], - "folders": ["FOLDER_NAME"] + "folders": ["FOLDER_NAME"], + "password": "Optional" } ``` diff --git a/Dockerfile b/Dockerfile index 6cf63f798..df893aafb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,8 +15,9 @@ RUN apk add --no-cache \ python3-dev \ g++ \ && pip3 install --no-cache-dir \ - uvloop \ - cchardet \ + uvloop==0.9.1 \ + cchardet==2.1.1 \ + pycryptodome==3.4.11 \ && apk del .build-dependencies # Install HassIO diff --git a/hassio/addons/__init__.py b/hassio/addons/__init__.py index 7dd78d788..d75dfab96 100644 --- a/hassio/addons/__init__.py +++ b/hassio/addons/__init__.py @@ -28,6 +28,12 @@ class AddonManager(CoreSysAttributes): """Return a list of all addons.""" return list(self.addons_obj.values()) + @property + def list_installed(self): + """Return a list of installed addons.""" + return [addon for addon in self.addons_obj.values() + if addon.is_installed] + @property def list_repositories(self): """Return list of addon repositories.""" diff --git a/hassio/addons/addon.py b/hassio/addons/addon.py index 6d283c245..83cd86d84 100644 --- a/hassio/addons/addon.py +++ b/hassio/addons/addon.py @@ -693,16 +693,15 @@ class Addon(CoreSysAttributes): return False # write into tarfile - def _create_tar(): + def _write_tarfile(): """Write tar inside loop.""" - with tarfile.open(tar_file, "w:gz", - compresslevel=1) as snapshot: + with tar_file as snapshot: snapshot.add(temp, arcname=".") snapshot.add(self.path_data, arcname="data") try: _LOGGER.info("Build snapshot for addon %s", self._id) - await self._loop.run_in_executor(None, _create_tar) + await self._loop.run_in_executor(None, _write_tarfile) except (tarfile.TarError, OSError) as err: _LOGGER.error("Can't write tarfile %s: %s", tar_file, err) return False @@ -714,13 +713,13 @@ class Addon(CoreSysAttributes): """Restore a state of a addon.""" with TemporaryDirectory(dir=str(self._config.path_tmp)) as temp: # extract snapshot - def _extract_tar(): + def _extract_tarfile(): """Extract tar snapshot.""" - with tarfile.open(tar_file, "r:gz") as snapshot: + with tar_file as snapshot: snapshot.extractall(path=Path(temp)) try: - await self._loop.run_in_executor(None, _extract_tar) + await self._loop.run_in_executor(None, _extract_tarfile) except tarfile.TarError as err: _LOGGER.error("Can't read tarfile %s: %s", tar_file, err) return False diff --git a/hassio/api/__init__.py b/hassio/api/__init__.py index d5e2d6e4a..e7297e961 100644 --- a/hassio/api/__init__.py +++ b/hassio/api/__init__.py @@ -160,6 +160,8 @@ class RestAPI(CoreSysAttributes): '/snapshots/new/full', api_snapshots.snapshot_full) self.webapp.router.add_post( '/snapshots/new/partial', api_snapshots.snapshot_partial) + self.webapp.router.add_post( + '/snapshots/new/upload', api_snapshots.upload) self.webapp.router.add_get( '/snapshots/{snapshot}/info', api_snapshots.info) @@ -170,6 +172,9 @@ class RestAPI(CoreSysAttributes): self.webapp.router.add_post( '/snapshots/{snapshot}/restore/partial', api_snapshots.restore_partial) + self.webapp.router.add_get( + '/snapshots/{snapshot}/download', + api_snapshots.download) def _register_services(self): api_services = APIServices() diff --git a/hassio/api/homeassistant.py b/hassio/api/homeassistant.py index 787467139..86bfaf296 100644 --- a/hassio/api/homeassistant.py +++ b/hassio/api/homeassistant.py @@ -9,7 +9,7 @@ from ..const import ( ATTR_VERSION, ATTR_LAST_VERSION, ATTR_IMAGE, ATTR_CUSTOM, ATTR_BOOT, ATTR_PORT, ATTR_PASSWORD, ATTR_SSL, ATTR_WATCHDOG, ATTR_CPU_PERCENT, ATTR_MEMORY_USAGE, ATTR_MEMORY_LIMIT, ATTR_NETWORK_RX, ATTR_NETWORK_TX, - ATTR_BLK_READ, ATTR_BLK_WRITE, ATTR_STARTUP_TIME, CONTENT_TYPE_BINARY) + ATTR_BLK_READ, ATTR_BLK_WRITE, ATTR_WAIT_BOOT, CONTENT_TYPE_BINARY) from ..coresys import CoreSysAttributes from ..validate import NETWORK_PORT, DOCKER_IMAGE @@ -27,7 +27,7 @@ SCHEMA_OPTIONS = vol.Schema({ vol.Optional(ATTR_PASSWORD): vol.Any(None, vol.Coerce(str)), vol.Optional(ATTR_SSL): vol.Boolean(), vol.Optional(ATTR_WATCHDOG): vol.Boolean(), - vol.Optional(ATTR_STARTUP_TIME): + vol.Optional(ATTR_WAIT_BOOT): vol.All(vol.Coerce(int), vol.Range(min=60)), }) @@ -51,7 +51,7 @@ class APIHomeAssistant(CoreSysAttributes): ATTR_PORT: self._homeassistant.api_port, ATTR_SSL: self._homeassistant.api_ssl, ATTR_WATCHDOG: self._homeassistant.watchdog, - ATTR_STARTUP_TIME: self._homeassistant.startup_time, + ATTR_WAIT_BOOT: self._homeassistant.wait_boot, } @api_process @@ -78,8 +78,8 @@ class APIHomeAssistant(CoreSysAttributes): if ATTR_WATCHDOG in body: self._homeassistant.watchdog = body[ATTR_WATCHDOG] - if ATTR_STARTUP_TIME in body: - self._homeassistant.startup_time = body[ATTR_STARTUP_TIME] + if ATTR_WAIT_BOOT in body: + self._homeassistant.wait_boot = body[ATTR_WAIT_BOOT] self._homeassistant.save_data() return True diff --git a/hassio/api/snapshots.py b/hassio/api/snapshots.py index 59f692328..9df56908d 100644 --- a/hassio/api/snapshots.py +++ b/hassio/api/snapshots.py @@ -1,7 +1,10 @@ """Init file for HassIO snapshot rest api.""" import asyncio import logging +from pathlib import Path +from tempfile import TemporaryDirectory +from aiohttp import web import voluptuous as vol from .utils import api_process, api_validate @@ -9,7 +12,7 @@ from ..snapshots.validate import ALL_FOLDERS from ..const import ( ATTR_NAME, ATTR_SLUG, ATTR_DATE, ATTR_ADDONS, ATTR_REPOSITORIES, ATTR_HOMEASSISTANT, ATTR_VERSION, ATTR_SIZE, ATTR_FOLDERS, ATTR_TYPE, - ATTR_SNAPSHOTS) + ATTR_SNAPSHOTS, ATTR_PASSWORD, ATTR_PROTECTED, CONTENT_TYPE_TAR) from ..coresys import CoreSysAttributes _LOGGER = logging.getLogger(__name__) @@ -17,6 +20,7 @@ _LOGGER = logging.getLogger(__name__) # pylint: disable=no-value-for-parameter SCHEMA_RESTORE_PARTIAL = vol.Schema({ + vol.Optional(ATTR_PASSWORD): vol.Any(None, vol.Coerce(str)), vol.Optional(ATTR_HOMEASSISTANT): vol.Boolean(), vol.Optional(ATTR_ADDONS): vol.All([vol.Coerce(str)], vol.Unique()), @@ -24,8 +28,13 @@ SCHEMA_RESTORE_PARTIAL = vol.Schema({ vol.All([vol.In(ALL_FOLDERS)], vol.Unique()), }) +SCHEMA_RESTORE_FULL = vol.Schema({ + vol.Optional(ATTR_PASSWORD): vol.Any(None, vol.Coerce(str)), +}) + SCHEMA_SNAPSHOT_FULL = vol.Schema({ vol.Optional(ATTR_NAME): vol.Coerce(str), + vol.Optional(ATTR_PASSWORD): vol.Any(None, vol.Coerce(str)), }) SCHEMA_SNAPSHOT_PARTIAL = SCHEMA_SNAPSHOT_FULL.extend({ @@ -56,6 +65,7 @@ class APISnapshots(CoreSysAttributes): ATTR_NAME: snapshot.name, ATTR_DATE: snapshot.date, ATTR_TYPE: snapshot.sys_type, + ATTR_PROTECTED: snapshot.protected, }) return { @@ -79,6 +89,7 @@ class APISnapshots(CoreSysAttributes): ATTR_SLUG: addon_data[ATTR_SLUG], ATTR_NAME: addon_data[ATTR_NAME], ATTR_VERSION: addon_data[ATTR_VERSION], + ATTR_SIZE: addon_data[ATTR_SIZE], }) return { @@ -87,6 +98,7 @@ class APISnapshots(CoreSysAttributes): ATTR_NAME: snapshot.name, ATTR_DATE: snapshot.date, ATTR_SIZE: snapshot.size, + ATTR_PROTECTED: snapshot.protected, ATTR_HOMEASSISTANT: snapshot.homeassistant_version, ATTR_ADDONS: data_addons, ATTR_REPOSITORIES: snapshot.repositories, @@ -108,17 +120,21 @@ class APISnapshots(CoreSysAttributes): self._snapshots.do_snapshot_partial(**body), loop=self._loop) @api_process - def restore_full(self, request): + async def restore_full(self, request): """Full-Restore a snapshot.""" snapshot = self._extract_snapshot(request) - return asyncio.shield( - self._snapshots.do_restore_full(snapshot), loop=self._loop) + body = await api_validate(SCHEMA_RESTORE_FULL, request) + + return await asyncio.shield( + self._snapshots.do_restore_full(snapshot, **body), + loop=self._loop + ) @api_process async def restore_partial(self, request): """Partial-Restore a snapshot.""" snapshot = self._extract_snapshot(request) - body = await api_validate(SCHEMA_SNAPSHOT_PARTIAL, request) + body = await api_validate(SCHEMA_RESTORE_PARTIAL, request) return await asyncio.shield( self._snapshots.do_restore_partial(snapshot, **body), @@ -130,3 +146,33 @@ class APISnapshots(CoreSysAttributes): """Remove a snapshot.""" snapshot = self._extract_snapshot(request) return self._snapshots.remove(snapshot) + + async def download(self, request): + """Download a snapshot file.""" + snapshot = self._extract_snapshot(request) + + _LOGGER.info("Download snapshot %s", snapshot.slug) + response = web.FileResponse(snapshot.tarfile) + response.content_type = CONTENT_TYPE_TAR + return response + + @api_process + async def upload(self, request): + """Upload a snapshot file.""" + with TemporaryDirectory(dir=str(self._config.path_tmp)) as temp_dir: + tar_file = Path(temp_dir, f"snapshot.tar") + + try: + with tar_file.open('wb') as snapshot: + async for data in request.content.iter_any(): + snapshot.write(data) + + except OSError as err: + _LOGGER.error("Can't write new snapshot file: %s", err) + return False + + except asyncio.CancelledError: + return False + + return await asyncio.shield( + self._snapshots.import_snapshot(tar_file), loop=self._loop) diff --git a/hassio/const.py b/hassio/const.py index b3f6ddcd7..651bee374 100644 --- a/hassio/const.py +++ b/hassio/const.py @@ -43,6 +43,7 @@ CONTENT_TYPE_BINARY = 'application/octet-stream' CONTENT_TYPE_PNG = 'image/png' CONTENT_TYPE_JSON = 'application/json' CONTENT_TYPE_TEXT = 'text/plain' +CONTENT_TYPE_TAR = 'application/tar' HEADER_HA_ACCESS = 'x-ha-access' HEADER_TOKEN = 'X-HASSIO-KEY' @@ -155,7 +156,8 @@ ATTR_CONFIG = 'config' ATTR_DISCOVERY_ID = 'discovery_id' ATTR_SERVICES = 'services' ATTR_DISCOVERY = 'discovery' -ATTR_STARTUP_TIME = 'startup_time' +ATTR_PROTECTED = 'protected' +ATTR_CRYPTO = 'crypto' SERVICE_MQTT = 'mqtt' @@ -193,3 +195,5 @@ FOLDER_SSL = 'ssl' SNAPSHOT_FULL = 'full' SNAPSHOT_PARTIAL = 'partial' + +CRYPTO_AES128 = 'aes128' diff --git a/hassio/homeassistant.py b/hassio/homeassistant.py index 5a70ff0f5..1b12664fe 100644 --- a/hassio/homeassistant.py +++ b/hassio/homeassistant.py @@ -13,7 +13,7 @@ from aiohttp.hdrs import CONTENT_TYPE from .const import ( FILE_HASSIO_HOMEASSISTANT, ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_UUID, ATTR_BOOT, ATTR_PASSWORD, ATTR_PORT, ATTR_SSL, ATTR_WATCHDOG, - ATTR_STARTUP_TIME, HEADER_HA_ACCESS, CONTENT_TYPE_JSON) + ATTR_WAIT_BOOT, HEADER_HA_ACCESS, CONTENT_TYPE_JSON) from .coresys import CoreSysAttributes from .docker.homeassistant import DockerHomeAssistant from .utils import convert_to_ascii @@ -97,14 +97,14 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): self._data[ATTR_WATCHDOG] = value @property - def startup_time(self): + def wait_boot(self): """Return time to wait for Home-Assistant startup.""" - return self._data[ATTR_STARTUP_TIME] + return self._data[ATTR_WAIT_BOOT] - @startup_time.setter - def startup_time(self, value): + @wait_boot.setter + def wait_boot(self, value): """Set time to wait for Home-Assistant startup.""" - self._data[ATTR_STARTUP_TIME] = value + self._data[ATTR_WAIT_BOOT] = value @property def version(self): @@ -343,7 +343,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): except OSError: pass - while time.monotonic() - start_time < self.startup_time: + while time.monotonic() - start_time < self.wait_boot: if await self._loop.run_in_executor(None, check_port): _LOGGER.info("Detect a running Home-Assistant instance") return True diff --git a/hassio/snapshots/__init__.py b/hassio/snapshots/__init__.py index af82a06e1..1911c0444 100644 --- a/hassio/snapshots/__init__.py +++ b/hassio/snapshots/__init__.py @@ -3,12 +3,11 @@ import asyncio from datetime import datetime import logging from pathlib import Path -import tarfile from .snapshot import Snapshot from .utils import create_slug from ..const import ( - ATTR_SLUG, FOLDER_HOMEASSISTANT, SNAPSHOT_FULL, SNAPSHOT_PARTIAL) + FOLDER_HOMEASSISTANT, SNAPSHOT_FULL, SNAPSHOT_PARTIAL) from ..coresys import CoreSysAttributes _LOGGER = logging.getLogger(__name__) @@ -32,15 +31,15 @@ class SnapshotManager(CoreSysAttributes): """Return snapshot object.""" return self.snapshots_obj.get(slug) - def _create_snapshot(self, name, sys_type): + def _create_snapshot(self, name, sys_type, password): """Initialize a new snapshot object from name.""" date_str = datetime.utcnow().isoformat() slug = create_slug(name, date_str) - tar_file = Path(self._config.path_backup, "{}.tar".format(slug)) + tar_file = Path(self._config.path_backup, f"{slug}.tar") # init object snapshot = Snapshot(self.coresys, tar_file) - snapshot.create(slug, name, date_str, sys_type) + snapshot.new(slug, name, date_str, sys_type, password) # set general data snapshot.store_homeassistant() @@ -75,45 +74,64 @@ class SnapshotManager(CoreSysAttributes): def remove(self, snapshot): """Remove a snapshot.""" try: - snapshot.tar_file.unlink() + snapshot.tarfile.unlink() self.snapshots_obj.pop(snapshot.slug, None) + _LOGGER.info("Removed snapshot file %s", snapshot.slug) + except OSError as err: _LOGGER.error("Can't remove snapshot %s: %s", snapshot.slug, err) return False return True - async def do_snapshot_full(self, name=""): + async def import_snapshot(self, tar_file): + """Check snapshot tarfile and import it.""" + snapshot = Snapshot(self.coresys, tar_file) + + # Read meta data + if not await snapshot.load(): + return False + + # Allready exists? + if snapshot.slug in self.snapshots_obj: + _LOGGER.error("Snapshot %s allready exists!", snapshot.slug) + return False + + # Move snapshot to backup + try: + snapshot.tarfile.rename( + Path(self._config.path_backup, f"{snapshot.slug}.tar")) + + except OSError as err: + _LOGGER.error("Can't move snapshot file to storage: %s", err) + return False + + await self.reload() + return True + + async def do_snapshot_full(self, name="", password=None): """Create a full snapshot.""" if self.lock.locked(): _LOGGER.error("It is already a snapshot/restore process running") return False - snapshot = self._create_snapshot(name, SNAPSHOT_FULL) + snapshot = self._create_snapshot(name, SNAPSHOT_FULL, password) _LOGGER.info("Full-Snapshot %s start", snapshot.slug) try: self._scheduler.suspend = True await self.lock.acquire() async with snapshot: - # snapshot addons - tasks = [] - for addon in self._addons.list_addons: - if not addon.is_installed: - continue - tasks.append(snapshot.import_addon(addon)) + # Snapshot add-ons + _LOGGER.info("Snapshot %s store Add-ons", snapshot.slug) + await snapshot.store_addons() - if tasks: - _LOGGER.info("Full-Snapshot %s run %d addons", - snapshot.slug, len(tasks)) - await asyncio.wait(tasks, loop=self._loop) - - # snapshot folders - _LOGGER.info("Full-Snapshot %s store folders", snapshot.slug) + # Snapshot folders + _LOGGER.info("Snapshot %s store folders", snapshot.slug) await snapshot.store_folders() - except (OSError, ValueError, tarfile.TarError) as err: - _LOGGER.info("Full-Snapshot %s error: %s", snapshot.slug, err) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Snapshot %s error", snapshot.slug) return False else: @@ -125,7 +143,8 @@ class SnapshotManager(CoreSysAttributes): self._scheduler.suspend = False self.lock.release() - async def do_snapshot_partial(self, name="", addons=None, folders=None): + async def do_snapshot_partial(self, name="", addons=None, folders=None, + password=None): """Create a partial snapshot.""" if self.lock.locked(): _LOGGER.error("It is already a snapshot/restore process running") @@ -133,7 +152,7 @@ class SnapshotManager(CoreSysAttributes): addons = addons or [] folders = folders or [] - snapshot = self._create_snapshot(name, SNAPSHOT_PARTIAL) + snapshot = self._create_snapshot(name, SNAPSHOT_PARTIAL, password) _LOGGER.info("Partial-Snapshot %s start", snapshot.slug) try: @@ -141,25 +160,24 @@ class SnapshotManager(CoreSysAttributes): await self.lock.acquire() async with snapshot: - # snapshot addons - tasks = [] - for slug in addons: - addon = self._addons.get(slug) - if addon.is_installed: - tasks.append(snapshot.import_addon(addon)) + # Snapshot add-ons + addon_list = [] + for addon_slug in addons: + addon = self._addons.get(addon_slug) + if addon and addon.is_installed: + addon_list.append(addon) + continue + _LOGGER.warning("Add-on %s not found", addon_slug) - if tasks: - _LOGGER.info("Partial-Snapshot %s run %d addons", - snapshot.slug, len(tasks)) - await asyncio.wait(tasks, loop=self._loop) + _LOGGER.info("Snapshot %s store Add-ons", snapshot.slug) + await snapshot.store_addons(addon_list) # snapshot folders - _LOGGER.info("Partial-Snapshot %s store folders %s", - snapshot.slug, folders) + _LOGGER.info("Snapshot %s store folders", snapshot.slug) await snapshot.store_folders(folders) - except (OSError, ValueError, tarfile.TarError) as err: - _LOGGER.info("Partial-Snapshot %s error: %s", snapshot.slug, err) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Snapshot %s error", snapshot.slug) return False else: @@ -171,15 +189,19 @@ class SnapshotManager(CoreSysAttributes): self._scheduler.suspend = False self.lock.release() - async def do_restore_full(self, snapshot): + async def do_restore_full(self, snapshot, password=None): """Restore a snapshot.""" if self.lock.locked(): _LOGGER.error("It is already a snapshot/restore process running") return False if snapshot.sys_type != SNAPSHOT_FULL: - _LOGGER.error( - "Full-Restore %s is only a partial snapshot!", snapshot.slug) + _LOGGER.error("Restore %s is only a partial snapshot!", + snapshot.slug) + return False + + if snapshot.protected and not snapshot.set_password(password): + _LOGGER.error("Invalid password for snapshot %s", snapshot.slug) return False _LOGGER.info("Full-Restore %s start", snapshot.slug) @@ -188,71 +210,54 @@ class SnapshotManager(CoreSysAttributes): await self.lock.acquire() async with snapshot: - # stop system tasks = [] - tasks.append(self._homeassistant.stop()) + # Stop Home-Assistant / Add-ons + tasks.append(self._homeassistant.stop()) for addon in self._addons.list_addons: if addon.is_installed: tasks.append(addon.stop()) - await asyncio.wait(tasks, loop=self._loop) + if tasks: + _LOGGER.info("Restore %s stop tasks", snapshot.slug) + await asyncio.wait(tasks, loop=self._loop) - # restore folders - _LOGGER.info("Full-Restore %s restore folders", snapshot.slug) + # Restore folders + _LOGGER.info("Restore %s run folders", snapshot.slug) await snapshot.restore_folders() - # start homeassistant restore - _LOGGER.info("Full-Restore %s restore Home-Assistant", - snapshot.slug) + # Start homeassistant restore + _LOGGER.info("Restore %s run Home-Assistant", snapshot.slug) snapshot.restore_homeassistant() task_hass = self._loop.create_task( self._homeassistant.update(snapshot.homeassistant_version)) - # restore repositories - _LOGGER.info("Full-Restore %s restore Repositories", - snapshot.slug) + # Restore repositories + _LOGGER.info("Restore %s run Repositories", snapshot.slug) await snapshot.restore_repositories() - # restore addons - tasks = [] - actual_addons = \ - set(addon.slug for addon in self._addons.list_addons - if addon.is_installed) - restore_addons = \ - set(data[ATTR_SLUG] for data in snapshot.addons) - remove_addons = actual_addons - restore_addons - - _LOGGER.info("Full-Restore %s restore addons %s, remove %s", - snapshot.slug, restore_addons, remove_addons) - - for slug in remove_addons: - addon = self._addons.get(slug) - if addon: + # Delete delta add-ons + tasks.clear() + for addon in self._addons.list_installed: + if addon.slug not in snapshot.addon_list: tasks.append(addon.uninstall()) - else: - _LOGGER.warning("Can't remove addon %s", snapshot.slug) - - for slug in restore_addons: - addon = self._addons.get(slug) - if addon: - tasks.append(snapshot.export_addon(addon)) - else: - _LOGGER.warning("Can't restore addon %s", slug) if tasks: - _LOGGER.info("Full-Restore %s restore addons tasks %d", - snapshot.slug, len(tasks)) + _LOGGER.info("Restore %s remove add-ons", snapshot.slug) await asyncio.wait(tasks, loop=self._loop) + # Restore add-ons + _LOGGER.info("Restore %s old add-ons", snapshot.slug) + await snapshot.restore_addons() + # finish homeassistant task - _LOGGER.info("Full-Restore %s wait until homeassistant ready", + _LOGGER.info("Restore %s wait until homeassistant ready", snapshot.slug) await task_hass await self._homeassistant.start() - except (OSError, ValueError, tarfile.TarError) as err: - _LOGGER.info("Full-Restore %s error: %s", snapshot.slug, err) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Restore %s error", snapshot.slug) return False else: @@ -264,12 +269,16 @@ class SnapshotManager(CoreSysAttributes): self.lock.release() async def do_restore_partial(self, snapshot, homeassistant=False, - addons=None, folders=None): + addons=None, folders=None, password=None): """Restore a snapshot.""" if self.lock.locked(): _LOGGER.error("It is already a snapshot/restore process running") return False + if snapshot.protected and not snapshot.set_password(password): + _LOGGER.error("Invalid password for snapshot %s", snapshot.slug) + return False + addons = addons or [] folders = folders or [] @@ -279,41 +288,46 @@ class SnapshotManager(CoreSysAttributes): await self.lock.acquire() async with snapshot: - tasks = [] - if FOLDER_HOMEASSISTANT in folders: await self._homeassistant.stop() + # Process folders if folders: - _LOGGER.info("Partial-Restore %s restore folders %s", - snapshot.slug, folders) + _LOGGER.info("Restore %s run folders", snapshot.slug) await snapshot.restore_folders(folders) + # Process Home-Assistant + task_hass = None if homeassistant: - _LOGGER.info("Partial-Restore %s restore Home-Assistant", + _LOGGER.info("Restore %s run Home-Assistant", snapshot.slug) snapshot.restore_homeassistant() - tasks.append(self._homeassistant.update( - snapshot.homeassistant_version)) + task_hass = self._loop.create_task( + self._homeassistant.update( + snapshot.homeassistant_version)) + # Process Add-ons + addon_list = [] for slug in addons: addon = self._addons.get(slug) if addon: - tasks.append(snapshot.export_addon(addon)) - else: - _LOGGER.warning("Can't restore addon %s", - snapshot.slug) + addon_list.append(addon) + continue + _LOGGER.warning("Can't restore addon %s", snapshot.slug) - if tasks: - _LOGGER.info("Partial-Restore %s run %d tasks", - snapshot.slug, len(tasks)) - await asyncio.wait(tasks, loop=self._loop) + if addon_list: + _LOGGER.info("Restore %s old add-ons", snapshot.slug) + await snapshot.restore_addons(addon_list) # make sure homeassistant run agen + if task_hass: + _LOGGER.info("Restore %s wait for Home-Assistant", + snapshot.slug) + await task_hass await self._homeassistant.start() - except (OSError, ValueError, tarfile.TarError) as err: - _LOGGER.info("Partial-Restore %s error: %s", snapshot.slug, err) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Restore %s error", snapshot.slug) return False else: diff --git a/hassio/snapshots/snapshot.py b/hassio/snapshots/snapshot.py index 5277b6331..da3019553 100644 --- a/hassio/snapshots/snapshot.py +++ b/hassio/snapshots/snapshot.py @@ -1,23 +1,28 @@ """Represent a snapshot file.""" import asyncio +from base64 import b64decode, b64encode import json import logging from pathlib import Path import tarfile from tempfile import TemporaryDirectory +from Crypto.Cipher import AES +from Crypto.Util import Padding import voluptuous as vol from voluptuous.humanize import humanize_error from .validate import SCHEMA_SNAPSHOT, ALL_FOLDERS -from .utils import remove_folder +from .utils import remove_folder, password_to_key, password_for_validating from ..const import ( ATTR_SLUG, ATTR_NAME, ATTR_DATE, ATTR_ADDONS, ATTR_REPOSITORIES, ATTR_HOMEASSISTANT, ATTR_FOLDERS, ATTR_VERSION, ATTR_TYPE, ATTR_IMAGE, - ATTR_PORT, ATTR_SSL, ATTR_PASSWORD, ATTR_WATCHDOG, ATTR_BOOT, - ATTR_LAST_VERSION, ATTR_STARTUP_TIME) + ATTR_PORT, ATTR_SSL, ATTR_PASSWORD, ATTR_WATCHDOG, ATTR_BOOT, ATTR_CRYPTO, + ATTR_LAST_VERSION, ATTR_PROTECTED, ATTR_WAIT_BOOT, ATTR_SIZE, + CRYPTO_AES128) from ..coresys import CoreSysAttributes from ..utils.json import write_json_file +from ..utils.tar import SecureTarFile _LOGGER = logging.getLogger(__name__) @@ -28,9 +33,11 @@ class Snapshot(CoreSysAttributes): def __init__(self, coresys, tar_file): """Initialize a snapshot.""" self.coresys = coresys - self.tar_file = tar_file + self._tarfile = tar_file self._data = {} self._tmp = None + self._key = None + self._aes = None @property def slug(self): @@ -52,11 +59,21 @@ class Snapshot(CoreSysAttributes): """Return snapshot date.""" return self._data[ATTR_DATE] + @property + def protected(self): + """Return snapshot date.""" + return self._data.get(ATTR_PROTECTED) is not None + @property def addons(self): """Return snapshot date.""" return self._data[ATTR_ADDONS] + @property + def addon_list(self): + """Return a list of addons slugs.""" + return [addon_data[ATTR_SLUG] for addon_data in self.addons] + @property def folders(self): """Return list of saved folders.""" @@ -77,99 +94,29 @@ class Snapshot(CoreSysAttributes): """Return snapshot homeassistant version.""" return self._data[ATTR_HOMEASSISTANT].get(ATTR_VERSION) - @homeassistant_version.setter - def homeassistant_version(self, value): - """Set snapshot homeassistant version.""" - self._data[ATTR_HOMEASSISTANT][ATTR_VERSION] = value - @property - def homeassistant_last_version(self): - """Return snapshot homeassistant last version (custom).""" - return self._data[ATTR_HOMEASSISTANT].get(ATTR_LAST_VERSION) - - @homeassistant_last_version.setter - def homeassistant_last_version(self, value): - """Set snapshot homeassistant last version (custom).""" - self._data[ATTR_HOMEASSISTANT][ATTR_LAST_VERSION] = value - - @property - def homeassistant_image(self): - """Return snapshot homeassistant custom image.""" - return self._data[ATTR_HOMEASSISTANT].get(ATTR_IMAGE) - - @homeassistant_image.setter - def homeassistant_image(self, value): - """Set snapshot homeassistant custom image.""" - self._data[ATTR_HOMEASSISTANT][ATTR_IMAGE] = value - - @property - def homeassistant_ssl(self): - """Return snapshot homeassistant api ssl.""" - return self._data[ATTR_HOMEASSISTANT].get(ATTR_SSL) - - @homeassistant_ssl.setter - def homeassistant_ssl(self, value): - """Set snapshot homeassistant api ssl.""" - self._data[ATTR_HOMEASSISTANT][ATTR_SSL] = value - - @property - def homeassistant_port(self): - """Return snapshot homeassistant api port.""" - return self._data[ATTR_HOMEASSISTANT].get(ATTR_PORT) - - @homeassistant_port.setter - def homeassistant_port(self, value): - """Set snapshot homeassistant api port.""" - self._data[ATTR_HOMEASSISTANT][ATTR_PORT] = value - - @property - def homeassistant_password(self): - """Return snapshot homeassistant api password.""" - return self._data[ATTR_HOMEASSISTANT].get(ATTR_PASSWORD) - - @homeassistant_password.setter - def homeassistant_password(self, value): - """Set snapshot homeassistant api password.""" - self._data[ATTR_HOMEASSISTANT][ATTR_PASSWORD] = value - - @property - def homeassistant_watchdog(self): - """Return snapshot homeassistant watchdog options.""" - return self._data[ATTR_HOMEASSISTANT].get(ATTR_WATCHDOG) - - @homeassistant_watchdog.setter - def homeassistant_watchdog(self, value): - """Set snapshot homeassistant watchdog options.""" - self._data[ATTR_HOMEASSISTANT][ATTR_WATCHDOG] = value - - @property - def homeassistant_startup_time(self): - """Return snapshot homeassistant startup time options.""" - return self._data[ATTR_HOMEASSISTANT].get(ATTR_STARTUP_TIME) - - @homeassistant_startup_time.setter - def homeassistant_startup_time(self, value): - """Set snapshot homeassistant startup time options.""" - self._data[ATTR_HOMEASSISTANT][ATTR_STARTUP_TIME] = value - - @property - def homeassistant_boot(self): - """Return snapshot homeassistant boot options.""" - return self._data[ATTR_HOMEASSISTANT].get(ATTR_BOOT) - - @homeassistant_boot.setter - def homeassistant_boot(self, value): - """Set snapshot homeassistant boot options.""" - self._data[ATTR_HOMEASSISTANT][ATTR_BOOT] = value + def homeassistant(self): + """Return snapshot homeassistant data.""" + return self._data[ATTR_HOMEASSISTANT] @property def size(self): """Return snapshot size.""" - if not self.tar_file.is_file(): + if not self.tarfile.is_file(): return 0 - return self.tar_file.stat().st_size / 1048576 # calc mbyte + return round(self.tarfile.stat().st_size / 1048576, 2) # calc mbyte - def create(self, slug, name, date, sys_type): + @property + def is_new(self): + """Return True if there is new.""" + return not self.tarfile.exists() + + @property + def tarfile(self): + """Return path to Snapshot tarfile.""" + return self._tarfile + + def new(self, slug, name, date, sys_type, password=None): """Initialize a new snapshot.""" # init metadata self._data[ATTR_SLUG] = slug @@ -180,15 +127,51 @@ class Snapshot(CoreSysAttributes): # Add defaults self._data = SCHEMA_SNAPSHOT(self._data) + # Set password + if password: + self._key = password_to_key(password) + self._aes = AES.new(self._key, AES.MODE_ECB) + self._data[ATTR_PROTECTED] = password_for_validating(password) + self._data[ATTR_CRYPTO] = CRYPTO_AES128 + + def set_password(self, password): + """Set the password for a exists snapshot.""" + if not password: + return False + + validating = password_for_validating(password) + if validating != self._data[ATTR_PROTECTED]: + return False + + self._key = password_to_key(password) + self._aes = AES.new(self._key, AES.MODE_ECB) + return True + + def _encrypt_data(self, data): + """Make data secure.""" + if not self._key: + return data + + return b64encode( + self._aes.encrypt(Padding.pad(data.encode(), 16))).decode() + + def _decrypt_data(self, data): + """Make data readable.""" + if not self._key: + return data + + return Padding.unpad( + self._aes.decrypt(b64decode(data)), 16).decode() + async def load(self): """Read snapshot.json from tar file.""" - if not self.tar_file.is_file(): - _LOGGER.error("No tarfile %s", self.tar_file) + if not self.tarfile.is_file(): + _LOGGER.error("No tarfile %s", self.tarfile) return False def _load_file(): """Read snapshot.json.""" - with tarfile.open(self.tar_file, "r:") as snapshot: + with tarfile.open(self.tarfile, "r:") as snapshot: json_file = snapshot.extractfile("./snapshot.json") return json_file.read() @@ -197,21 +180,21 @@ class Snapshot(CoreSysAttributes): raw = await self._loop.run_in_executor(None, _load_file) except (tarfile.TarError, KeyError) as err: _LOGGER.error( - "Can't read snapshot tarfile %s: %s", self.tar_file, err) + "Can't read snapshot tarfile %s: %s", self.tarfile, err) return False # parse data try: raw_dict = json.loads(raw) except json.JSONDecodeError as err: - _LOGGER.error("Can't read data for %s: %s", self.tar_file, err) + _LOGGER.error("Can't read data for %s: %s", self.tarfile, err) return False # validate try: self._data = SCHEMA_SNAPSHOT(raw_dict) except vol.Invalid as err: - _LOGGER.error("Can't validate data for %s: %s", self.tar_file, + _LOGGER.error("Can't validate data for %s: %s", self.tarfile, humanize_error(raw_dict, err)) return False @@ -222,13 +205,13 @@ class Snapshot(CoreSysAttributes): self._tmp = TemporaryDirectory(dir=str(self._config.path_tmp)) # create a snapshot - if not self.tar_file.is_file(): + if not self.tarfile.is_file(): return self # extract a exists snapshot def _extract_snapshot(): """Extract a snapshot.""" - with tarfile.open(self.tar_file, "r:") as tar: + with tarfile.open(self.tarfile, "r:") as tar: tar.extractall(path=self._tmp.name) await self._loop.run_in_executor(None, _extract_snapshot) @@ -236,7 +219,7 @@ class Snapshot(CoreSysAttributes): async def __aexit__(self, exception_type, exception_value, traceback): """Async context to close a snapshot.""" # exists snapshot or exception on build - if self.tar_file.is_file() or exception_type is not None: + if self.tarfile.is_file() or exception_type is not None: self._tmp.cleanup() return @@ -244,14 +227,14 @@ class Snapshot(CoreSysAttributes): try: self._data = SCHEMA_SNAPSHOT(self._data) except vol.Invalid as err: - _LOGGER.error("Invalid data for %s: %s", self.tar_file, + _LOGGER.error("Invalid data for %s: %s", self.tarfile, humanize_error(self._data, err)) raise ValueError("Invalid config") from None # new snapshot, build it def _create_snapshot(): """Create a new snapshot.""" - with tarfile.open(self.tar_file, "w:") as tar: + with tarfile.open(self.tarfile, "w:") as tar: tar.add(self._tmp.name, arcname=".") try: @@ -262,32 +245,63 @@ class Snapshot(CoreSysAttributes): finally: self._tmp.cleanup() - async def import_addon(self, addon): - """Add a addon into snapshot.""" - snapshot_file = Path(self._tmp.name, "{}.tar.gz".format(addon.slug)) + async def store_addons(self, addon_list=None): + """Add a list of add-ons into snapshot.""" + addon_list = addon_list or self._addons.list_installed - if not await addon.snapshot(snapshot_file): - _LOGGER.error("Can't make snapshot from %s", addon.slug) - return False + async def _addon_save(addon): + """Task to store a add-on into snapshot.""" + addon_file = SecureTarFile( + Path(self._tmp.name, f"{addon.slug}.tar.gz"), + 'w', key=self._key) - # store to config - self._data[ATTR_ADDONS].append({ - ATTR_SLUG: addon.slug, - ATTR_NAME: addon.name, - ATTR_VERSION: addon.version_installed, - }) + # Take snapshot + if not await addon.snapshot(addon_file): + _LOGGER.error("Can't make snapshot from %s", addon.slug) + return - return True + # Store to config + self._data[ATTR_ADDONS].append({ + ATTR_SLUG: addon.slug, + ATTR_NAME: addon.name, + ATTR_VERSION: addon.version_installed, + ATTR_SIZE: addon_file.size, + }) - async def export_addon(self, addon): - """Restore a addon from snapshot.""" - snapshot_file = Path(self._tmp.name, "{}.tar.gz".format(addon.slug)) + # Run tasks + tasks = [_addon_save(addon) for addon in addon_list] + if tasks: + await asyncio.wait(tasks, loop=self._loop) - if not await addon.restore(snapshot_file): - _LOGGER.error("Can't restore snapshot for %s", addon.slug) - return False + async def restore_addons(self, addon_list=None): + """Restore a list add-on from snapshot.""" + if not addon_list: + addon_list = [] + for addon_slug in self.addon_list: + addon = self._addons.get(addon_slug) + if addon: + addon_list.append(addon) - return True + async def _addon_restore(addon): + """Task to restore a add-on into snapshot.""" + addon_file = SecureTarFile( + Path(self._tmp.name, f"{addon.slug}.tar.gz"), + 'r', key=self._key) + + # If exists inside snapshot + if not addon_file.path.exists(): + _LOGGER.error("Can't find snapshot for %s", addon.slug) + return + + # Performe a restore + if not await addon.restore(addon_file): + _LOGGER.error("Can't restore snapshot for %s", addon.slug) + return + + # Run tasks + tasks = [_addon_restore(addon) for addon in addon_list] + if tasks: + await asyncio.wait(tasks, loop=self._loop) async def store_folders(self, folder_list=None): """Backup hassio data into snapshot.""" @@ -296,13 +310,18 @@ class Snapshot(CoreSysAttributes): def _folder_save(name): """Intenal function to snapshot a folder.""" slug_name = name.replace("/", "_") - snapshot_tar = Path(self._tmp.name, "{}.tar.gz".format(slug_name)) + tar_name = Path(self._tmp.name, f"{slug_name}.tar.gz") origin_dir = Path(self._config.path_hassio, name) + # Check if exsits + if not origin_dir.is_dir(): + _LOGGER.warning("Can't find snapshot folder %s", name) + return + + # Take snapshot try: _LOGGER.info("Snapshot folder %s", name) - with tarfile.open(snapshot_tar, "w:gz", - compresslevel=1) as tar_file: + with SecureTarFile(tar_name, 'w', key=self._key) as tar_file: tar_file.add(origin_dir, arcname=".") _LOGGER.info("Snapshot folder %s done", name) @@ -310,7 +329,7 @@ class Snapshot(CoreSysAttributes): except (tarfile.TarError, OSError) as err: _LOGGER.warning("Can't snapshot folder %s: %s", name, err) - # run tasks + # Run tasks tasks = [self._loop.run_in_executor(None, _folder_save, folder) for folder in folder_list] if tasks: @@ -323,22 +342,28 @@ class Snapshot(CoreSysAttributes): def _folder_restore(name): """Intenal function to restore a folder.""" slug_name = name.replace("/", "_") - snapshot_tar = Path(self._tmp.name, "{}.tar.gz".format(slug_name)) + tar_name = Path(self._tmp.name, f"{slug_name}.tar.gz") origin_dir = Path(self._config.path_hassio, name) - # clean old stuff + # Check if exists inside snapshot + if not tar_name.exists(): + _LOGGER.warning("Can't find restore folder %s", name) + return + + # Clean old stuff if origin_dir.is_dir(): remove_folder(origin_dir) + # Performe a restore try: _LOGGER.info("Restore folder %s", name) - with tarfile.open(snapshot_tar, "r:gz") as tar_file: + with SecureTarFile(tar_name, 'r', key=self._key) as tar_file: tar_file.extractall(path=origin_dir) _LOGGER.info("Restore folder %s done", name) except (tarfile.TarError, OSError) as err: _LOGGER.warning("Can't restore folder %s: %s", name, err) - # run tasks + # Run tasks tasks = [self._loop.run_in_executor(None, _folder_restore, folder) for folder in folder_list] if tasks: @@ -346,36 +371,40 @@ class Snapshot(CoreSysAttributes): def store_homeassistant(self): """Read all data from homeassistant object.""" - self.homeassistant_version = self._homeassistant.version - self.homeassistant_watchdog = self._homeassistant.watchdog - self.homeassistant_boot = self._homeassistant.boot - self.homeassistant_startup_time = self._homeassistant.startup_time + self.homeassistant[ATTR_VERSION] = self._homeassistant.version + self.homeassistant[ATTR_WATCHDOG] = self._homeassistant.watchdog + self.homeassistant[ATTR_BOOT] = self._homeassistant.boot + self.homeassistant[ATTR_WAIT_BOOT] = self._homeassistant.wait_boot - # custom image + # Custom image if self._homeassistant.is_custom_image: - self.homeassistant_image = self._homeassistant.image - self.homeassistant_last_version = self._homeassistant.last_version + self.homeassistant[ATTR_IMAGE] = self._homeassistant.image + self.homeassistant[ATTR_LAST_VERSION] = \ + self._homeassistant.last_version - # api - self.homeassistant_port = self._homeassistant.api_port - self.homeassistant_ssl = self._homeassistant.api_ssl - self.homeassistant_password = self._homeassistant.api_password + # API/Proxy + self.homeassistant[ATTR_PORT] = self._homeassistant.api_port + self.homeassistant[ATTR_SSL] = self._homeassistant.api_ssl + self.homeassistant[ATTR_PASSWORD] = \ + self._encrypt_data(self._homeassistant.api_password) def restore_homeassistant(self): """Write all data to homeassistant object.""" - self._homeassistant.watchdog = self.homeassistant_watchdog - self._homeassistant.boot = self.homeassistant_boot - self._homeassistant.startup_time = self.homeassistant_startup_time + self._homeassistant.watchdog = self.homeassistant[ATTR_WATCHDOG] + self._homeassistant.boot = self.homeassistant[ATTR_BOOT] + self._homeassistant.wait_boot = self.homeassistant[ATTR_WAIT_BOOT] - # custom image - if self.homeassistant_image: - self._homeassistant.image = self.homeassistant_image - self._homeassistant.last_version = self.homeassistant_last_version + # Custom image + if self.homeassistant.get(ATTR_IMAGE): + self._homeassistant.image = self.homeassistant[ATTR_IMAGE] + self._homeassistant.last_version = \ + self.homeassistant[ATTR_LAST_VERSION] - # api - self._homeassistant.api_port = self.homeassistant_port - self._homeassistant.api_ssl = self.homeassistant_ssl - self._homeassistant.api_password = self.homeassistant_password + # API/Proxy + self._homeassistant.api_port = self.homeassistant[ATTR_PORT] + self._homeassistant.api_ssl = self.homeassistant[ATTR_SSL] + self._homeassistant.api_password = \ + self._decrypt_data(self.homeassistant[ATTR_PASSWORD]) # save self._homeassistant.save_data() diff --git a/hassio/snapshots/utils.py b/hassio/snapshots/utils.py index af75b3ffa..64b334f57 100644 --- a/hassio/snapshots/utils.py +++ b/hassio/snapshots/utils.py @@ -3,6 +3,21 @@ import hashlib import shutil +def password_to_key(password): + """Generate a AES Key from password""" + password = password.encode() + for _ in range(100): + password = hashlib.sha256(password).digest() + return password[:16] + + +def password_for_validating(password): + """Generate a SHA256 hash from password""" + for _ in range(100): + password = hashlib.sha256(password.encode()).hexdigest() + return password + + def create_slug(name, date_str): """Generate a hash from repository.""" key = "{} - {}".format(date_str, name).lower().encode() diff --git a/hassio/snapshots/validate.py b/hassio/snapshots/validate.py index e9b7d7f59..2c8ea9eae 100644 --- a/hassio/snapshots/validate.py +++ b/hassio/snapshots/validate.py @@ -5,10 +5,10 @@ import voluptuous as vol from ..const import ( ATTR_REPOSITORIES, ATTR_ADDONS, ATTR_NAME, ATTR_SLUG, ATTR_DATE, ATTR_VERSION, ATTR_HOMEASSISTANT, ATTR_FOLDERS, ATTR_TYPE, ATTR_IMAGE, - ATTR_PASSWORD, ATTR_PORT, ATTR_SSL, ATTR_WATCHDOG, ATTR_BOOT, - ATTR_LAST_VERSION, ATTR_STARTUP_TIME, + ATTR_PASSWORD, ATTR_PORT, ATTR_SSL, ATTR_WATCHDOG, ATTR_BOOT, ATTR_SIZE, + ATTR_LAST_VERSION, ATTR_WAIT_BOOT, ATTR_PROTECTED, ATTR_CRYPTO, FOLDER_SHARE, FOLDER_HOMEASSISTANT, FOLDER_ADDONS, FOLDER_SSL, - SNAPSHOT_FULL, SNAPSHOT_PARTIAL) + SNAPSHOT_FULL, SNAPSHOT_PARTIAL, CRYPTO_AES128) from ..validate import NETWORK_PORT, REPOSITORIES, DOCKER_IMAGE ALL_FOLDERS = [FOLDER_HOMEASSISTANT, FOLDER_SHARE, FOLDER_ADDONS, FOLDER_SSL] @@ -29,8 +29,11 @@ SCHEMA_SNAPSHOT = vol.Schema({ vol.Required(ATTR_TYPE): vol.In([SNAPSHOT_FULL, SNAPSHOT_PARTIAL]), vol.Required(ATTR_NAME): vol.Coerce(str), vol.Required(ATTR_DATE): vol.Coerce(str), + vol.Inclusive(ATTR_PROTECTED, 'encrypted'): + vol.All(vol.Coerce(str), vol.Length(64)), + vol.Inclusive(ATTR_CRYPTO, 'encrypted'): CRYPTO_AES128, vol.Optional(ATTR_HOMEASSISTANT, default=dict): vol.Schema({ - vol.Required(ATTR_VERSION): vol.Coerce(str), + vol.Optional(ATTR_VERSION): vol.Coerce(str), vol.Inclusive(ATTR_IMAGE, 'custom_hass'): DOCKER_IMAGE, vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'): vol.Coerce(str), vol.Optional(ATTR_BOOT, default=True): vol.Boolean(), @@ -38,7 +41,7 @@ SCHEMA_SNAPSHOT = vol.Schema({ vol.Optional(ATTR_PORT, default=8123): NETWORK_PORT, vol.Optional(ATTR_PASSWORD): vol.Any(None, vol.Coerce(str)), vol.Optional(ATTR_WATCHDOG, default=True): vol.Boolean(), - vol.Optional(ATTR_STARTUP_TIME, default=600): + vol.Optional(ATTR_WAIT_BOOT, default=600): vol.All(vol.Coerce(int), vol.Range(min=60)), }, extra=vol.REMOVE_EXTRA), vol.Optional(ATTR_FOLDERS, default=list): @@ -47,6 +50,7 @@ SCHEMA_SNAPSHOT = vol.Schema({ vol.Required(ATTR_SLUG): vol.Coerce(str), vol.Required(ATTR_NAME): vol.Coerce(str), vol.Required(ATTR_VERSION): vol.Coerce(str), + vol.Optional(ATTR_SIZE, default=0): vol.Coerce(float), }, extra=vol.REMOVE_EXTRA)], unique_addons), vol.Optional(ATTR_REPOSITORIES, default=list): REPOSITORIES, }, extra=vol.ALLOW_EXTRA) diff --git a/hassio/utils/tar.py b/hassio/utils/tar.py new file mode 100644 index 000000000..60c139293 --- /dev/null +++ b/hassio/utils/tar.py @@ -0,0 +1,78 @@ +"""Tarfile fileobject handler for encrypted files.""" +import tarfile + +from Crypto.Cipher import AES +from Crypto.Random import get_random_bytes +from Crypto.Util.Padding import pad + +BLOCK_SIZE = 16 + +MOD_READ = 'r' +MOD_WRITE = 'w' + + +class SecureTarFile(object): + """Handle encrypted files for tarfile library.""" + + def __init__(self, name, mode, key=None, gzip=True): + """Initialize encryption handler.""" + self._file = None + self._mode = mode + self._name = name + + # Tarfile options + self._tar = None + self._tar_mode = f"{mode}|gz" if gzip else f"{mode}|" + + # Encryption/Decription + self._aes = None + self._key = key + + def __enter__(self): + """Start context manager tarfile.""" + if not self._key: + self._tar = tarfile.open(name=str(self._name), mode=self._tar_mode) + return self._tar + + # Encrypted/Decryped Tarfile + self._file = self._name.open(f"{self._mode}b") + + # Extract IV for CBC + if self._mode == MOD_READ: + cbc_iv = self._file.read(16) + else: + cbc_iv = get_random_bytes(16) + self._file.write(cbc_iv) + self._aes = AES.new(self._key, AES.MODE_CBC, iv=cbc_iv) + + self._tar = tarfile.open(fileobj=self, mode=self._tar_mode) + return self._tar + + def __exit__(self, exc_type, exc_value, traceback): + """Close file.""" + if self._tar: + self._tar.close() + if self._file: + self._file.close() + + def write(self, data): + """Write data.""" + if len(data) % BLOCK_SIZE != 0: + data = pad(data, BLOCK_SIZE) + self._file.write(self._aes.encrypt(data)) + + def read(self, size=0): + """Read data.""" + return self._aes.decrypt(self._file.read(size)) + + @property + def path(self): + """Return path object of tarfile.""" + return self._name + + @property + def size(self): + """Return snapshot size.""" + if not self._name.is_file(): + return 0 + return round(self._name.stat().st_size / 1048576, 2) # calc mbyte diff --git a/hassio/validate.py b/hassio/validate.py index 9605e3f89..ae52aca85 100644 --- a/hassio/validate.py +++ b/hassio/validate.py @@ -8,8 +8,7 @@ from .const import ( ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_BETA_CHANNEL, ATTR_TIMEZONE, ATTR_ADDONS_CUSTOM_LIST, ATTR_AUDIO_OUTPUT, ATTR_AUDIO_INPUT, ATTR_PASSWORD, ATTR_HOMEASSISTANT, ATTR_HASSIO, ATTR_BOOT, ATTR_LAST_BOOT, - ATTR_SSL, ATTR_PORT, ATTR_WATCHDOG, ATTR_WAIT_BOOT, ATTR_UUID, - ATTR_STARTUP_TIME) + ATTR_SSL, ATTR_PORT, ATTR_WATCHDOG, ATTR_WAIT_BOOT, ATTR_UUID) NETWORK_PORT = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)) @@ -73,7 +72,7 @@ SCHEMA_HASS_CONFIG = vol.Schema({ vol.Optional(ATTR_PASSWORD): vol.Any(None, vol.Coerce(str)), vol.Optional(ATTR_SSL, default=False): vol.Boolean(), vol.Optional(ATTR_WATCHDOG, default=True): vol.Boolean(), - vol.Optional(ATTR_STARTUP_TIME, default=600): + vol.Optional(ATTR_WAIT_BOOT, default=600): vol.All(vol.Coerce(int), vol.Range(min=60)), }, extra=vol.REMOVE_EXTRA) diff --git a/setup.py b/setup.py index ab6ad16ef..f5698177b 100644 --- a/setup.py +++ b/setup.py @@ -44,9 +44,10 @@ setup( 'aiohttp==2.3.10', 'docker==3.0.1', 'colorlog==3.1.2', - 'voluptuous==0.10.5', + 'voluptuous==0.11.1', 'gitpython==2.1.8', 'pytz==2018.3', - 'pyudev==0.21.0' + 'pyudev==0.21.0', + 'pycryptodome==3.4.11' ] )