Add support for encrypted snapshot files (#354)

* Add support for encrypted files

* Update tar.py

* Update tar.py

* Update tar.py

* Update addon.py

* Update API.md

* Update API.md

* Update tar.py

* cleanup snapshot

* Update API.md

* Update const.py

* Update const.py

* Update validate.py

* Update homeassistant.py

* Update homeassistant.py

* Update validate.py

* Update validate.py

* Update snapshot.py

* Update utils.py

* Update snapshot.py

* Update utils.py

* Update snapshot.py

* Update validate.py

* Update snapshot.py

* Update validate.py

* Update const.py

* fix lint

* Update snapshot.py

* Update __init__.py

* Update snapshot.py

* Update __init__.py

* Update __init__.py

* Finish snapshot object

* Fix struct

* cleanup snapshot flow

* fix some points

* Add API upload

* fix lint

* Update voluptuous

* fix docker

* Update snapshots.py

* fix versions

* fix schema

* fix schema

* fix api

* fix path

* Handle import better

* fix routing

* fix bugs

* fix bug

* cleanup gz

* fix some bugs

* fix stage

* Fix

* fix

* protect None password

* fix API

* handle exception better

* fix

* fix remove of addons

* fix bug

* clenaup code

* fix none tasks

* Encrypt Home-Assistant

* fix decrypt

* fix binary
This commit is contained in:
Pascal Vizeli 2018-02-17 15:52:33 +01:00 committed by GitHub
parent e815223047
commit 587047f9d6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 512 additions and 293 deletions

28
API.md
View File

@ -113,7 +113,8 @@ Output is the raw docker log.
"slug": "SLUG", "slug": "SLUG",
"date": "ISO", "date": "ISO",
"name": "Custom name", "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/reload`
- POST `/snapshots/new/upload`
- POST `/snapshots/new/full` - POST `/snapshots/new/full`
```json ```json
{ {
"name": "Optional" "name": "Optional",
"password": "Optional"
} }
``` ```
@ -135,7 +139,8 @@ Output is the raw docker log.
{ {
"name": "Optional", "name": "Optional",
"addons": ["ADDON_SLUG"], "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", "name": "custom snapshot name / description",
"date": "ISO", "date": "ISO",
"size": "SIZE_IN_MB", "size": "SIZE_IN_MB",
"protected": "bool",
"homeassistant": "version", "homeassistant": "version",
"addons": [ "addons": [
{ {
"slug": "ADDON_SLUG", "slug": "ADDON_SLUG",
"name": "NAME", "name": "NAME",
"version": "INSTALLED_VERSION" "version": "INSTALLED_VERSION",
"size": "SIZE_IN_MB"
} }
], ],
"repositories": ["URL"], "repositories": ["URL"],
@ -164,14 +171,25 @@ Output is the raw docker log.
``` ```
- POST `/snapshots/{slug}/remove` - POST `/snapshots/{slug}/remove`
- GET `/snapshots/{slug}/download`
- POST `/snapshots/{slug}/restore/full` - POST `/snapshots/{slug}/restore/full`
```json
{
"password": "Optional"
}
```
- POST `/snapshots/{slug}/restore/partial` - POST `/snapshots/{slug}/restore/partial`
```json ```json
{ {
"homeassistant": "bool", "homeassistant": "bool",
"addons": ["ADDON_SLUG"], "addons": ["ADDON_SLUG"],
"folders": ["FOLDER_NAME"] "folders": ["FOLDER_NAME"],
"password": "Optional"
} }
``` ```

View File

@ -15,8 +15,9 @@ RUN apk add --no-cache \
python3-dev \ python3-dev \
g++ \ g++ \
&& pip3 install --no-cache-dir \ && pip3 install --no-cache-dir \
uvloop \ uvloop==0.9.1 \
cchardet \ cchardet==2.1.1 \
pycryptodome==3.4.11 \
&& apk del .build-dependencies && apk del .build-dependencies
# Install HassIO # Install HassIO

View File

@ -28,6 +28,12 @@ class AddonManager(CoreSysAttributes):
"""Return a list of all addons.""" """Return a list of all addons."""
return list(self.addons_obj.values()) 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 @property
def list_repositories(self): def list_repositories(self):
"""Return list of addon repositories.""" """Return list of addon repositories."""

View File

@ -693,16 +693,15 @@ class Addon(CoreSysAttributes):
return False return False
# write into tarfile # write into tarfile
def _create_tar(): def _write_tarfile():
"""Write tar inside loop.""" """Write tar inside loop."""
with tarfile.open(tar_file, "w:gz", with tar_file as snapshot:
compresslevel=1) as snapshot:
snapshot.add(temp, arcname=".") snapshot.add(temp, arcname=".")
snapshot.add(self.path_data, arcname="data") snapshot.add(self.path_data, arcname="data")
try: try:
_LOGGER.info("Build snapshot for addon %s", self._id) _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: except (tarfile.TarError, OSError) as err:
_LOGGER.error("Can't write tarfile %s: %s", tar_file, err) _LOGGER.error("Can't write tarfile %s: %s", tar_file, err)
return False return False
@ -714,13 +713,13 @@ class Addon(CoreSysAttributes):
"""Restore a state of a addon.""" """Restore a state of a addon."""
with TemporaryDirectory(dir=str(self._config.path_tmp)) as temp: with TemporaryDirectory(dir=str(self._config.path_tmp)) as temp:
# extract snapshot # extract snapshot
def _extract_tar(): def _extract_tarfile():
"""Extract tar snapshot.""" """Extract tar snapshot."""
with tarfile.open(tar_file, "r:gz") as snapshot: with tar_file as snapshot:
snapshot.extractall(path=Path(temp)) snapshot.extractall(path=Path(temp))
try: try:
await self._loop.run_in_executor(None, _extract_tar) await self._loop.run_in_executor(None, _extract_tarfile)
except tarfile.TarError as err: except tarfile.TarError as err:
_LOGGER.error("Can't read tarfile %s: %s", tar_file, err) _LOGGER.error("Can't read tarfile %s: %s", tar_file, err)
return False return False

View File

@ -160,6 +160,8 @@ class RestAPI(CoreSysAttributes):
'/snapshots/new/full', api_snapshots.snapshot_full) '/snapshots/new/full', api_snapshots.snapshot_full)
self.webapp.router.add_post( self.webapp.router.add_post(
'/snapshots/new/partial', api_snapshots.snapshot_partial) '/snapshots/new/partial', api_snapshots.snapshot_partial)
self.webapp.router.add_post(
'/snapshots/new/upload', api_snapshots.upload)
self.webapp.router.add_get( self.webapp.router.add_get(
'/snapshots/{snapshot}/info', api_snapshots.info) '/snapshots/{snapshot}/info', api_snapshots.info)
@ -170,6 +172,9 @@ class RestAPI(CoreSysAttributes):
self.webapp.router.add_post( self.webapp.router.add_post(
'/snapshots/{snapshot}/restore/partial', '/snapshots/{snapshot}/restore/partial',
api_snapshots.restore_partial) api_snapshots.restore_partial)
self.webapp.router.add_get(
'/snapshots/{snapshot}/download',
api_snapshots.download)
def _register_services(self): def _register_services(self):
api_services = APIServices() api_services = APIServices()

View File

@ -9,7 +9,7 @@ from ..const import (
ATTR_VERSION, ATTR_LAST_VERSION, ATTR_IMAGE, ATTR_CUSTOM, ATTR_BOOT, ATTR_VERSION, ATTR_LAST_VERSION, ATTR_IMAGE, ATTR_CUSTOM, ATTR_BOOT,
ATTR_PORT, ATTR_PASSWORD, ATTR_SSL, ATTR_WATCHDOG, ATTR_CPU_PERCENT, ATTR_PORT, ATTR_PASSWORD, ATTR_SSL, ATTR_WATCHDOG, ATTR_CPU_PERCENT,
ATTR_MEMORY_USAGE, ATTR_MEMORY_LIMIT, ATTR_NETWORK_RX, ATTR_NETWORK_TX, 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 ..coresys import CoreSysAttributes
from ..validate import NETWORK_PORT, DOCKER_IMAGE 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_PASSWORD): vol.Any(None, vol.Coerce(str)),
vol.Optional(ATTR_SSL): vol.Boolean(), vol.Optional(ATTR_SSL): vol.Boolean(),
vol.Optional(ATTR_WATCHDOG): 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)), vol.All(vol.Coerce(int), vol.Range(min=60)),
}) })
@ -51,7 +51,7 @@ class APIHomeAssistant(CoreSysAttributes):
ATTR_PORT: self._homeassistant.api_port, ATTR_PORT: self._homeassistant.api_port,
ATTR_SSL: self._homeassistant.api_ssl, ATTR_SSL: self._homeassistant.api_ssl,
ATTR_WATCHDOG: self._homeassistant.watchdog, ATTR_WATCHDOG: self._homeassistant.watchdog,
ATTR_STARTUP_TIME: self._homeassistant.startup_time, ATTR_WAIT_BOOT: self._homeassistant.wait_boot,
} }
@api_process @api_process
@ -78,8 +78,8 @@ class APIHomeAssistant(CoreSysAttributes):
if ATTR_WATCHDOG in body: if ATTR_WATCHDOG in body:
self._homeassistant.watchdog = body[ATTR_WATCHDOG] self._homeassistant.watchdog = body[ATTR_WATCHDOG]
if ATTR_STARTUP_TIME in body: if ATTR_WAIT_BOOT in body:
self._homeassistant.startup_time = body[ATTR_STARTUP_TIME] self._homeassistant.wait_boot = body[ATTR_WAIT_BOOT]
self._homeassistant.save_data() self._homeassistant.save_data()
return True return True

View File

@ -1,7 +1,10 @@
"""Init file for HassIO snapshot rest api.""" """Init file for HassIO snapshot rest api."""
import asyncio import asyncio
import logging import logging
from pathlib import Path
from tempfile import TemporaryDirectory
from aiohttp import web
import voluptuous as vol import voluptuous as vol
from .utils import api_process, api_validate from .utils import api_process, api_validate
@ -9,7 +12,7 @@ from ..snapshots.validate import ALL_FOLDERS
from ..const import ( from ..const import (
ATTR_NAME, ATTR_SLUG, ATTR_DATE, ATTR_ADDONS, ATTR_REPOSITORIES, ATTR_NAME, ATTR_SLUG, ATTR_DATE, ATTR_ADDONS, ATTR_REPOSITORIES,
ATTR_HOMEASSISTANT, ATTR_VERSION, ATTR_SIZE, ATTR_FOLDERS, ATTR_TYPE, 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 from ..coresys import CoreSysAttributes
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -17,6 +20,7 @@ _LOGGER = logging.getLogger(__name__)
# pylint: disable=no-value-for-parameter # pylint: disable=no-value-for-parameter
SCHEMA_RESTORE_PARTIAL = vol.Schema({ SCHEMA_RESTORE_PARTIAL = vol.Schema({
vol.Optional(ATTR_PASSWORD): vol.Any(None, vol.Coerce(str)),
vol.Optional(ATTR_HOMEASSISTANT): vol.Boolean(), vol.Optional(ATTR_HOMEASSISTANT): vol.Boolean(),
vol.Optional(ATTR_ADDONS): vol.Optional(ATTR_ADDONS):
vol.All([vol.Coerce(str)], vol.Unique()), vol.All([vol.Coerce(str)], vol.Unique()),
@ -24,8 +28,13 @@ SCHEMA_RESTORE_PARTIAL = vol.Schema({
vol.All([vol.In(ALL_FOLDERS)], vol.Unique()), 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({ SCHEMA_SNAPSHOT_FULL = vol.Schema({
vol.Optional(ATTR_NAME): vol.Coerce(str), vol.Optional(ATTR_NAME): vol.Coerce(str),
vol.Optional(ATTR_PASSWORD): vol.Any(None, vol.Coerce(str)),
}) })
SCHEMA_SNAPSHOT_PARTIAL = SCHEMA_SNAPSHOT_FULL.extend({ SCHEMA_SNAPSHOT_PARTIAL = SCHEMA_SNAPSHOT_FULL.extend({
@ -56,6 +65,7 @@ class APISnapshots(CoreSysAttributes):
ATTR_NAME: snapshot.name, ATTR_NAME: snapshot.name,
ATTR_DATE: snapshot.date, ATTR_DATE: snapshot.date,
ATTR_TYPE: snapshot.sys_type, ATTR_TYPE: snapshot.sys_type,
ATTR_PROTECTED: snapshot.protected,
}) })
return { return {
@ -79,6 +89,7 @@ class APISnapshots(CoreSysAttributes):
ATTR_SLUG: addon_data[ATTR_SLUG], ATTR_SLUG: addon_data[ATTR_SLUG],
ATTR_NAME: addon_data[ATTR_NAME], ATTR_NAME: addon_data[ATTR_NAME],
ATTR_VERSION: addon_data[ATTR_VERSION], ATTR_VERSION: addon_data[ATTR_VERSION],
ATTR_SIZE: addon_data[ATTR_SIZE],
}) })
return { return {
@ -87,6 +98,7 @@ class APISnapshots(CoreSysAttributes):
ATTR_NAME: snapshot.name, ATTR_NAME: snapshot.name,
ATTR_DATE: snapshot.date, ATTR_DATE: snapshot.date,
ATTR_SIZE: snapshot.size, ATTR_SIZE: snapshot.size,
ATTR_PROTECTED: snapshot.protected,
ATTR_HOMEASSISTANT: snapshot.homeassistant_version, ATTR_HOMEASSISTANT: snapshot.homeassistant_version,
ATTR_ADDONS: data_addons, ATTR_ADDONS: data_addons,
ATTR_REPOSITORIES: snapshot.repositories, ATTR_REPOSITORIES: snapshot.repositories,
@ -108,17 +120,21 @@ class APISnapshots(CoreSysAttributes):
self._snapshots.do_snapshot_partial(**body), loop=self._loop) self._snapshots.do_snapshot_partial(**body), loop=self._loop)
@api_process @api_process
def restore_full(self, request): async def restore_full(self, request):
"""Full-Restore a snapshot.""" """Full-Restore a snapshot."""
snapshot = self._extract_snapshot(request) snapshot = self._extract_snapshot(request)
return asyncio.shield( body = await api_validate(SCHEMA_RESTORE_FULL, request)
self._snapshots.do_restore_full(snapshot), loop=self._loop)
return await asyncio.shield(
self._snapshots.do_restore_full(snapshot, **body),
loop=self._loop
)
@api_process @api_process
async def restore_partial(self, request): async def restore_partial(self, request):
"""Partial-Restore a snapshot.""" """Partial-Restore a snapshot."""
snapshot = self._extract_snapshot(request) 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( return await asyncio.shield(
self._snapshots.do_restore_partial(snapshot, **body), self._snapshots.do_restore_partial(snapshot, **body),
@ -130,3 +146,33 @@ class APISnapshots(CoreSysAttributes):
"""Remove a snapshot.""" """Remove a snapshot."""
snapshot = self._extract_snapshot(request) snapshot = self._extract_snapshot(request)
return self._snapshots.remove(snapshot) 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)

View File

@ -43,6 +43,7 @@ CONTENT_TYPE_BINARY = 'application/octet-stream'
CONTENT_TYPE_PNG = 'image/png' CONTENT_TYPE_PNG = 'image/png'
CONTENT_TYPE_JSON = 'application/json' CONTENT_TYPE_JSON = 'application/json'
CONTENT_TYPE_TEXT = 'text/plain' CONTENT_TYPE_TEXT = 'text/plain'
CONTENT_TYPE_TAR = 'application/tar'
HEADER_HA_ACCESS = 'x-ha-access' HEADER_HA_ACCESS = 'x-ha-access'
HEADER_TOKEN = 'X-HASSIO-KEY' HEADER_TOKEN = 'X-HASSIO-KEY'
@ -155,7 +156,8 @@ ATTR_CONFIG = 'config'
ATTR_DISCOVERY_ID = 'discovery_id' ATTR_DISCOVERY_ID = 'discovery_id'
ATTR_SERVICES = 'services' ATTR_SERVICES = 'services'
ATTR_DISCOVERY = 'discovery' ATTR_DISCOVERY = 'discovery'
ATTR_STARTUP_TIME = 'startup_time' ATTR_PROTECTED = 'protected'
ATTR_CRYPTO = 'crypto'
SERVICE_MQTT = 'mqtt' SERVICE_MQTT = 'mqtt'
@ -193,3 +195,5 @@ FOLDER_SSL = 'ssl'
SNAPSHOT_FULL = 'full' SNAPSHOT_FULL = 'full'
SNAPSHOT_PARTIAL = 'partial' SNAPSHOT_PARTIAL = 'partial'
CRYPTO_AES128 = 'aes128'

View File

@ -13,7 +13,7 @@ from aiohttp.hdrs import CONTENT_TYPE
from .const import ( from .const import (
FILE_HASSIO_HOMEASSISTANT, ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_UUID, FILE_HASSIO_HOMEASSISTANT, ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_UUID,
ATTR_BOOT, ATTR_PASSWORD, ATTR_PORT, ATTR_SSL, ATTR_WATCHDOG, 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 .coresys import CoreSysAttributes
from .docker.homeassistant import DockerHomeAssistant from .docker.homeassistant import DockerHomeAssistant
from .utils import convert_to_ascii from .utils import convert_to_ascii
@ -97,14 +97,14 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
self._data[ATTR_WATCHDOG] = value self._data[ATTR_WATCHDOG] = value
@property @property
def startup_time(self): def wait_boot(self):
"""Return time to wait for Home-Assistant startup.""" """Return time to wait for Home-Assistant startup."""
return self._data[ATTR_STARTUP_TIME] return self._data[ATTR_WAIT_BOOT]
@startup_time.setter @wait_boot.setter
def startup_time(self, value): def wait_boot(self, value):
"""Set time to wait for Home-Assistant startup.""" """Set time to wait for Home-Assistant startup."""
self._data[ATTR_STARTUP_TIME] = value self._data[ATTR_WAIT_BOOT] = value
@property @property
def version(self): def version(self):
@ -343,7 +343,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
except OSError: except OSError:
pass 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): if await self._loop.run_in_executor(None, check_port):
_LOGGER.info("Detect a running Home-Assistant instance") _LOGGER.info("Detect a running Home-Assistant instance")
return True return True

View File

@ -3,12 +3,11 @@ import asyncio
from datetime import datetime from datetime import datetime
import logging import logging
from pathlib import Path from pathlib import Path
import tarfile
from .snapshot import Snapshot from .snapshot import Snapshot
from .utils import create_slug from .utils import create_slug
from ..const import ( from ..const import (
ATTR_SLUG, FOLDER_HOMEASSISTANT, SNAPSHOT_FULL, SNAPSHOT_PARTIAL) FOLDER_HOMEASSISTANT, SNAPSHOT_FULL, SNAPSHOT_PARTIAL)
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -32,15 +31,15 @@ class SnapshotManager(CoreSysAttributes):
"""Return snapshot object.""" """Return snapshot object."""
return self.snapshots_obj.get(slug) 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.""" """Initialize a new snapshot object from name."""
date_str = datetime.utcnow().isoformat() date_str = datetime.utcnow().isoformat()
slug = create_slug(name, date_str) 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 # init object
snapshot = Snapshot(self.coresys, tar_file) 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 # set general data
snapshot.store_homeassistant() snapshot.store_homeassistant()
@ -75,45 +74,64 @@ class SnapshotManager(CoreSysAttributes):
def remove(self, snapshot): def remove(self, snapshot):
"""Remove a snapshot.""" """Remove a snapshot."""
try: try:
snapshot.tar_file.unlink() snapshot.tarfile.unlink()
self.snapshots_obj.pop(snapshot.slug, None) self.snapshots_obj.pop(snapshot.slug, None)
_LOGGER.info("Removed snapshot file %s", snapshot.slug)
except OSError as err: except OSError as err:
_LOGGER.error("Can't remove snapshot %s: %s", snapshot.slug, err) _LOGGER.error("Can't remove snapshot %s: %s", snapshot.slug, err)
return False return False
return True 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.""" """Create a full snapshot."""
if self.lock.locked(): if self.lock.locked():
_LOGGER.error("It is already a snapshot/restore process running") _LOGGER.error("It is already a snapshot/restore process running")
return False 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) _LOGGER.info("Full-Snapshot %s start", snapshot.slug)
try: try:
self._scheduler.suspend = True self._scheduler.suspend = True
await self.lock.acquire() await self.lock.acquire()
async with snapshot: async with snapshot:
# snapshot addons # Snapshot add-ons
tasks = [] _LOGGER.info("Snapshot %s store Add-ons", snapshot.slug)
for addon in self._addons.list_addons: await snapshot.store_addons()
if not addon.is_installed:
continue
tasks.append(snapshot.import_addon(addon))
if tasks: # Snapshot folders
_LOGGER.info("Full-Snapshot %s run %d addons", _LOGGER.info("Snapshot %s store folders", snapshot.slug)
snapshot.slug, len(tasks))
await asyncio.wait(tasks, loop=self._loop)
# snapshot folders
_LOGGER.info("Full-Snapshot %s store folders", snapshot.slug)
await snapshot.store_folders() await snapshot.store_folders()
except (OSError, ValueError, tarfile.TarError) as err: except Exception: # pylint: disable=broad-except
_LOGGER.info("Full-Snapshot %s error: %s", snapshot.slug, err) _LOGGER.exception("Snapshot %s error", snapshot.slug)
return False return False
else: else:
@ -125,7 +143,8 @@ class SnapshotManager(CoreSysAttributes):
self._scheduler.suspend = False self._scheduler.suspend = False
self.lock.release() 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.""" """Create a partial snapshot."""
if self.lock.locked(): if self.lock.locked():
_LOGGER.error("It is already a snapshot/restore process running") _LOGGER.error("It is already a snapshot/restore process running")
@ -133,7 +152,7 @@ class SnapshotManager(CoreSysAttributes):
addons = addons or [] addons = addons or []
folders = folders 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) _LOGGER.info("Partial-Snapshot %s start", snapshot.slug)
try: try:
@ -141,25 +160,24 @@ class SnapshotManager(CoreSysAttributes):
await self.lock.acquire() await self.lock.acquire()
async with snapshot: async with snapshot:
# snapshot addons # Snapshot add-ons
tasks = [] addon_list = []
for slug in addons: for addon_slug in addons:
addon = self._addons.get(slug) addon = self._addons.get(addon_slug)
if addon.is_installed: if addon and addon.is_installed:
tasks.append(snapshot.import_addon(addon)) addon_list.append(addon)
continue
_LOGGER.warning("Add-on %s not found", addon_slug)
if tasks: _LOGGER.info("Snapshot %s store Add-ons", snapshot.slug)
_LOGGER.info("Partial-Snapshot %s run %d addons", await snapshot.store_addons(addon_list)
snapshot.slug, len(tasks))
await asyncio.wait(tasks, loop=self._loop)
# snapshot folders # snapshot folders
_LOGGER.info("Partial-Snapshot %s store folders %s", _LOGGER.info("Snapshot %s store folders", snapshot.slug)
snapshot.slug, folders)
await snapshot.store_folders(folders) await snapshot.store_folders(folders)
except (OSError, ValueError, tarfile.TarError) as err: except Exception: # pylint: disable=broad-except
_LOGGER.info("Partial-Snapshot %s error: %s", snapshot.slug, err) _LOGGER.exception("Snapshot %s error", snapshot.slug)
return False return False
else: else:
@ -171,15 +189,19 @@ class SnapshotManager(CoreSysAttributes):
self._scheduler.suspend = False self._scheduler.suspend = False
self.lock.release() self.lock.release()
async def do_restore_full(self, snapshot): async def do_restore_full(self, snapshot, password=None):
"""Restore a snapshot.""" """Restore a snapshot."""
if self.lock.locked(): if self.lock.locked():
_LOGGER.error("It is already a snapshot/restore process running") _LOGGER.error("It is already a snapshot/restore process running")
return False return False
if snapshot.sys_type != SNAPSHOT_FULL: if snapshot.sys_type != SNAPSHOT_FULL:
_LOGGER.error( _LOGGER.error("Restore %s is only a partial snapshot!",
"Full-Restore %s is only a partial snapshot!", snapshot.slug) snapshot.slug)
return False
if snapshot.protected and not snapshot.set_password(password):
_LOGGER.error("Invalid password for snapshot %s", snapshot.slug)
return False return False
_LOGGER.info("Full-Restore %s start", snapshot.slug) _LOGGER.info("Full-Restore %s start", snapshot.slug)
@ -188,71 +210,54 @@ class SnapshotManager(CoreSysAttributes):
await self.lock.acquire() await self.lock.acquire()
async with snapshot: async with snapshot:
# stop system
tasks = [] tasks = []
tasks.append(self._homeassistant.stop())
# Stop Home-Assistant / Add-ons
tasks.append(self._homeassistant.stop())
for addon in self._addons.list_addons: for addon in self._addons.list_addons:
if addon.is_installed: if addon.is_installed:
tasks.append(addon.stop()) tasks.append(addon.stop())
if tasks:
_LOGGER.info("Restore %s stop tasks", snapshot.slug)
await asyncio.wait(tasks, loop=self._loop) await asyncio.wait(tasks, loop=self._loop)
# restore folders # Restore folders
_LOGGER.info("Full-Restore %s restore folders", snapshot.slug) _LOGGER.info("Restore %s run folders", snapshot.slug)
await snapshot.restore_folders() await snapshot.restore_folders()
# start homeassistant restore # Start homeassistant restore
_LOGGER.info("Full-Restore %s restore Home-Assistant", _LOGGER.info("Restore %s run Home-Assistant", snapshot.slug)
snapshot.slug)
snapshot.restore_homeassistant() snapshot.restore_homeassistant()
task_hass = self._loop.create_task( task_hass = self._loop.create_task(
self._homeassistant.update(snapshot.homeassistant_version)) self._homeassistant.update(snapshot.homeassistant_version))
# restore repositories # Restore repositories
_LOGGER.info("Full-Restore %s restore Repositories", _LOGGER.info("Restore %s run Repositories", snapshot.slug)
snapshot.slug)
await snapshot.restore_repositories() await snapshot.restore_repositories()
# restore addons # Delete delta add-ons
tasks = [] tasks.clear()
actual_addons = \ for addon in self._addons.list_installed:
set(addon.slug for addon in self._addons.list_addons if addon.slug not in snapshot.addon_list:
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:
tasks.append(addon.uninstall()) 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: if tasks:
_LOGGER.info("Full-Restore %s restore addons tasks %d", _LOGGER.info("Restore %s remove add-ons", snapshot.slug)
snapshot.slug, len(tasks))
await asyncio.wait(tasks, loop=self._loop) 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 # finish homeassistant task
_LOGGER.info("Full-Restore %s wait until homeassistant ready", _LOGGER.info("Restore %s wait until homeassistant ready",
snapshot.slug) snapshot.slug)
await task_hass await task_hass
await self._homeassistant.start() await self._homeassistant.start()
except (OSError, ValueError, tarfile.TarError) as err: except Exception: # pylint: disable=broad-except
_LOGGER.info("Full-Restore %s error: %s", snapshot.slug, err) _LOGGER.exception("Restore %s error", snapshot.slug)
return False return False
else: else:
@ -264,12 +269,16 @@ class SnapshotManager(CoreSysAttributes):
self.lock.release() self.lock.release()
async def do_restore_partial(self, snapshot, homeassistant=False, async def do_restore_partial(self, snapshot, homeassistant=False,
addons=None, folders=None): addons=None, folders=None, password=None):
"""Restore a snapshot.""" """Restore a snapshot."""
if self.lock.locked(): if self.lock.locked():
_LOGGER.error("It is already a snapshot/restore process running") _LOGGER.error("It is already a snapshot/restore process running")
return False 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 [] addons = addons or []
folders = folders or [] folders = folders or []
@ -279,41 +288,46 @@ class SnapshotManager(CoreSysAttributes):
await self.lock.acquire() await self.lock.acquire()
async with snapshot: async with snapshot:
tasks = []
if FOLDER_HOMEASSISTANT in folders: if FOLDER_HOMEASSISTANT in folders:
await self._homeassistant.stop() await self._homeassistant.stop()
# Process folders
if folders: if folders:
_LOGGER.info("Partial-Restore %s restore folders %s", _LOGGER.info("Restore %s run folders", snapshot.slug)
snapshot.slug, folders)
await snapshot.restore_folders(folders) await snapshot.restore_folders(folders)
# Process Home-Assistant
task_hass = None
if homeassistant: if homeassistant:
_LOGGER.info("Partial-Restore %s restore Home-Assistant", _LOGGER.info("Restore %s run Home-Assistant",
snapshot.slug) snapshot.slug)
snapshot.restore_homeassistant() snapshot.restore_homeassistant()
tasks.append(self._homeassistant.update( task_hass = self._loop.create_task(
self._homeassistant.update(
snapshot.homeassistant_version)) snapshot.homeassistant_version))
# Process Add-ons
addon_list = []
for slug in addons: for slug in addons:
addon = self._addons.get(slug) addon = self._addons.get(slug)
if addon: if addon:
tasks.append(snapshot.export_addon(addon)) addon_list.append(addon)
else: continue
_LOGGER.warning("Can't restore addon %s", _LOGGER.warning("Can't restore addon %s", snapshot.slug)
snapshot.slug)
if tasks: if addon_list:
_LOGGER.info("Partial-Restore %s run %d tasks", _LOGGER.info("Restore %s old add-ons", snapshot.slug)
snapshot.slug, len(tasks)) await snapshot.restore_addons(addon_list)
await asyncio.wait(tasks, loop=self._loop)
# make sure homeassistant run agen # 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() await self._homeassistant.start()
except (OSError, ValueError, tarfile.TarError) as err: except Exception: # pylint: disable=broad-except
_LOGGER.info("Partial-Restore %s error: %s", snapshot.slug, err) _LOGGER.exception("Restore %s error", snapshot.slug)
return False return False
else: else:

View File

@ -1,23 +1,28 @@
"""Represent a snapshot file.""" """Represent a snapshot file."""
import asyncio import asyncio
from base64 import b64decode, b64encode
import json import json
import logging import logging
from pathlib import Path from pathlib import Path
import tarfile import tarfile
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
from Crypto.Cipher import AES
from Crypto.Util import Padding
import voluptuous as vol import voluptuous as vol
from voluptuous.humanize import humanize_error from voluptuous.humanize import humanize_error
from .validate import SCHEMA_SNAPSHOT, ALL_FOLDERS 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 ( from ..const import (
ATTR_SLUG, ATTR_NAME, ATTR_DATE, ATTR_ADDONS, ATTR_REPOSITORIES, ATTR_SLUG, ATTR_NAME, ATTR_DATE, ATTR_ADDONS, ATTR_REPOSITORIES,
ATTR_HOMEASSISTANT, ATTR_FOLDERS, ATTR_VERSION, ATTR_TYPE, ATTR_IMAGE, ATTR_HOMEASSISTANT, ATTR_FOLDERS, ATTR_VERSION, ATTR_TYPE, ATTR_IMAGE,
ATTR_PORT, ATTR_SSL, ATTR_PASSWORD, ATTR_WATCHDOG, ATTR_BOOT, ATTR_PORT, ATTR_SSL, ATTR_PASSWORD, ATTR_WATCHDOG, ATTR_BOOT, ATTR_CRYPTO,
ATTR_LAST_VERSION, ATTR_STARTUP_TIME) ATTR_LAST_VERSION, ATTR_PROTECTED, ATTR_WAIT_BOOT, ATTR_SIZE,
CRYPTO_AES128)
from ..coresys import CoreSysAttributes from ..coresys import CoreSysAttributes
from ..utils.json import write_json_file from ..utils.json import write_json_file
from ..utils.tar import SecureTarFile
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -28,9 +33,11 @@ class Snapshot(CoreSysAttributes):
def __init__(self, coresys, tar_file): def __init__(self, coresys, tar_file):
"""Initialize a snapshot.""" """Initialize a snapshot."""
self.coresys = coresys self.coresys = coresys
self.tar_file = tar_file self._tarfile = tar_file
self._data = {} self._data = {}
self._tmp = None self._tmp = None
self._key = None
self._aes = None
@property @property
def slug(self): def slug(self):
@ -52,11 +59,21 @@ class Snapshot(CoreSysAttributes):
"""Return snapshot date.""" """Return snapshot date."""
return self._data[ATTR_DATE] return self._data[ATTR_DATE]
@property
def protected(self):
"""Return snapshot date."""
return self._data.get(ATTR_PROTECTED) is not None
@property @property
def addons(self): def addons(self):
"""Return snapshot date.""" """Return snapshot date."""
return self._data[ATTR_ADDONS] 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 @property
def folders(self): def folders(self):
"""Return list of saved folders.""" """Return list of saved folders."""
@ -77,99 +94,29 @@ class Snapshot(CoreSysAttributes):
"""Return snapshot homeassistant version.""" """Return snapshot homeassistant version."""
return self._data[ATTR_HOMEASSISTANT].get(ATTR_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 @property
def homeassistant_last_version(self): def homeassistant(self):
"""Return snapshot homeassistant last version (custom).""" """Return snapshot homeassistant data."""
return self._data[ATTR_HOMEASSISTANT].get(ATTR_LAST_VERSION) return self._data[ATTR_HOMEASSISTANT]
@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
@property @property
def size(self): def size(self):
"""Return snapshot size.""" """Return snapshot size."""
if not self.tar_file.is_file(): if not self.tarfile.is_file():
return 0 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.""" """Initialize a new snapshot."""
# init metadata # init metadata
self._data[ATTR_SLUG] = slug self._data[ATTR_SLUG] = slug
@ -180,15 +127,51 @@ class Snapshot(CoreSysAttributes):
# Add defaults # Add defaults
self._data = SCHEMA_SNAPSHOT(self._data) 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): async def load(self):
"""Read snapshot.json from tar file.""" """Read snapshot.json from tar file."""
if not self.tar_file.is_file(): if not self.tarfile.is_file():
_LOGGER.error("No tarfile %s", self.tar_file) _LOGGER.error("No tarfile %s", self.tarfile)
return False return False
def _load_file(): def _load_file():
"""Read snapshot.json.""" """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") json_file = snapshot.extractfile("./snapshot.json")
return json_file.read() return json_file.read()
@ -197,21 +180,21 @@ class Snapshot(CoreSysAttributes):
raw = await self._loop.run_in_executor(None, _load_file) raw = await self._loop.run_in_executor(None, _load_file)
except (tarfile.TarError, KeyError) as err: except (tarfile.TarError, KeyError) as err:
_LOGGER.error( _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 return False
# parse data # parse data
try: try:
raw_dict = json.loads(raw) raw_dict = json.loads(raw)
except json.JSONDecodeError as err: 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 return False
# validate # validate
try: try:
self._data = SCHEMA_SNAPSHOT(raw_dict) self._data = SCHEMA_SNAPSHOT(raw_dict)
except vol.Invalid as err: 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)) humanize_error(raw_dict, err))
return False return False
@ -222,13 +205,13 @@ class Snapshot(CoreSysAttributes):
self._tmp = TemporaryDirectory(dir=str(self._config.path_tmp)) self._tmp = TemporaryDirectory(dir=str(self._config.path_tmp))
# create a snapshot # create a snapshot
if not self.tar_file.is_file(): if not self.tarfile.is_file():
return self return self
# extract a exists snapshot # extract a exists snapshot
def _extract_snapshot(): def _extract_snapshot():
"""Extract a 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) tar.extractall(path=self._tmp.name)
await self._loop.run_in_executor(None, _extract_snapshot) 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 def __aexit__(self, exception_type, exception_value, traceback):
"""Async context to close a snapshot.""" """Async context to close a snapshot."""
# exists snapshot or exception on build # 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() self._tmp.cleanup()
return return
@ -244,14 +227,14 @@ class Snapshot(CoreSysAttributes):
try: try:
self._data = SCHEMA_SNAPSHOT(self._data) self._data = SCHEMA_SNAPSHOT(self._data)
except vol.Invalid as err: 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)) humanize_error(self._data, err))
raise ValueError("Invalid config") from None raise ValueError("Invalid config") from None
# new snapshot, build it # new snapshot, build it
def _create_snapshot(): def _create_snapshot():
"""Create a new 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=".") tar.add(self._tmp.name, arcname=".")
try: try:
@ -262,32 +245,63 @@ class Snapshot(CoreSysAttributes):
finally: finally:
self._tmp.cleanup() self._tmp.cleanup()
async def import_addon(self, addon): async def store_addons(self, addon_list=None):
"""Add a addon into snapshot.""" """Add a list of add-ons into snapshot."""
snapshot_file = Path(self._tmp.name, "{}.tar.gz".format(addon.slug)) addon_list = addon_list or self._addons.list_installed
if not await addon.snapshot(snapshot_file): 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)
# Take snapshot
if not await addon.snapshot(addon_file):
_LOGGER.error("Can't make snapshot from %s", addon.slug) _LOGGER.error("Can't make snapshot from %s", addon.slug)
return False return
# store to config # Store to config
self._data[ATTR_ADDONS].append({ self._data[ATTR_ADDONS].append({
ATTR_SLUG: addon.slug, ATTR_SLUG: addon.slug,
ATTR_NAME: addon.name, ATTR_NAME: addon.name,
ATTR_VERSION: addon.version_installed, ATTR_VERSION: addon.version_installed,
ATTR_SIZE: addon_file.size,
}) })
return True # Run tasks
tasks = [_addon_save(addon) for addon in addon_list]
if tasks:
await asyncio.wait(tasks, loop=self._loop)
async def export_addon(self, addon): async def restore_addons(self, addon_list=None):
"""Restore a addon from snapshot.""" """Restore a list add-on from snapshot."""
snapshot_file = Path(self._tmp.name, "{}.tar.gz".format(addon.slug)) 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)
if not await addon.restore(snapshot_file): 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) _LOGGER.error("Can't restore snapshot for %s", addon.slug)
return False return
return True # 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): async def store_folders(self, folder_list=None):
"""Backup hassio data into snapshot.""" """Backup hassio data into snapshot."""
@ -296,13 +310,18 @@ class Snapshot(CoreSysAttributes):
def _folder_save(name): def _folder_save(name):
"""Intenal function to snapshot a folder.""" """Intenal function to snapshot a folder."""
slug_name = name.replace("/", "_") 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) 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: try:
_LOGGER.info("Snapshot folder %s", name) _LOGGER.info("Snapshot folder %s", name)
with tarfile.open(snapshot_tar, "w:gz", with SecureTarFile(tar_name, 'w', key=self._key) as tar_file:
compresslevel=1) as tar_file:
tar_file.add(origin_dir, arcname=".") tar_file.add(origin_dir, arcname=".")
_LOGGER.info("Snapshot folder %s done", name) _LOGGER.info("Snapshot folder %s done", name)
@ -310,7 +329,7 @@ class Snapshot(CoreSysAttributes):
except (tarfile.TarError, OSError) as err: except (tarfile.TarError, OSError) as err:
_LOGGER.warning("Can't snapshot folder %s: %s", name, 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) tasks = [self._loop.run_in_executor(None, _folder_save, folder)
for folder in folder_list] for folder in folder_list]
if tasks: if tasks:
@ -323,22 +342,28 @@ class Snapshot(CoreSysAttributes):
def _folder_restore(name): def _folder_restore(name):
"""Intenal function to restore a folder.""" """Intenal function to restore a folder."""
slug_name = name.replace("/", "_") 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) 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(): if origin_dir.is_dir():
remove_folder(origin_dir) remove_folder(origin_dir)
# Performe a restore
try: try:
_LOGGER.info("Restore folder %s", name) _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) tar_file.extractall(path=origin_dir)
_LOGGER.info("Restore folder %s done", name) _LOGGER.info("Restore folder %s done", name)
except (tarfile.TarError, OSError) as err: except (tarfile.TarError, OSError) as err:
_LOGGER.warning("Can't restore folder %s: %s", name, 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) tasks = [self._loop.run_in_executor(None, _folder_restore, folder)
for folder in folder_list] for folder in folder_list]
if tasks: if tasks:
@ -346,36 +371,40 @@ class Snapshot(CoreSysAttributes):
def store_homeassistant(self): def store_homeassistant(self):
"""Read all data from homeassistant object.""" """Read all data from homeassistant object."""
self.homeassistant_version = self._homeassistant.version self.homeassistant[ATTR_VERSION] = self._homeassistant.version
self.homeassistant_watchdog = self._homeassistant.watchdog self.homeassistant[ATTR_WATCHDOG] = self._homeassistant.watchdog
self.homeassistant_boot = self._homeassistant.boot self.homeassistant[ATTR_BOOT] = self._homeassistant.boot
self.homeassistant_startup_time = self._homeassistant.startup_time self.homeassistant[ATTR_WAIT_BOOT] = self._homeassistant.wait_boot
# custom image # Custom image
if self._homeassistant.is_custom_image: if self._homeassistant.is_custom_image:
self.homeassistant_image = self._homeassistant.image self.homeassistant[ATTR_IMAGE] = self._homeassistant.image
self.homeassistant_last_version = self._homeassistant.last_version self.homeassistant[ATTR_LAST_VERSION] = \
self._homeassistant.last_version
# api # API/Proxy
self.homeassistant_port = self._homeassistant.api_port self.homeassistant[ATTR_PORT] = self._homeassistant.api_port
self.homeassistant_ssl = self._homeassistant.api_ssl self.homeassistant[ATTR_SSL] = self._homeassistant.api_ssl
self.homeassistant_password = self._homeassistant.api_password self.homeassistant[ATTR_PASSWORD] = \
self._encrypt_data(self._homeassistant.api_password)
def restore_homeassistant(self): def restore_homeassistant(self):
"""Write all data to homeassistant object.""" """Write all data to homeassistant object."""
self._homeassistant.watchdog = self.homeassistant_watchdog self._homeassistant.watchdog = self.homeassistant[ATTR_WATCHDOG]
self._homeassistant.boot = self.homeassistant_boot self._homeassistant.boot = self.homeassistant[ATTR_BOOT]
self._homeassistant.startup_time = self.homeassistant_startup_time self._homeassistant.wait_boot = self.homeassistant[ATTR_WAIT_BOOT]
# custom image # Custom image
if self.homeassistant_image: if self.homeassistant.get(ATTR_IMAGE):
self._homeassistant.image = self.homeassistant_image self._homeassistant.image = self.homeassistant[ATTR_IMAGE]
self._homeassistant.last_version = self.homeassistant_last_version self._homeassistant.last_version = \
self.homeassistant[ATTR_LAST_VERSION]
# api # API/Proxy
self._homeassistant.api_port = self.homeassistant_port self._homeassistant.api_port = self.homeassistant[ATTR_PORT]
self._homeassistant.api_ssl = self.homeassistant_ssl self._homeassistant.api_ssl = self.homeassistant[ATTR_SSL]
self._homeassistant.api_password = self.homeassistant_password self._homeassistant.api_password = \
self._decrypt_data(self.homeassistant[ATTR_PASSWORD])
# save # save
self._homeassistant.save_data() self._homeassistant.save_data()

View File

@ -3,6 +3,21 @@ import hashlib
import shutil 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): def create_slug(name, date_str):
"""Generate a hash from repository.""" """Generate a hash from repository."""
key = "{} - {}".format(date_str, name).lower().encode() key = "{} - {}".format(date_str, name).lower().encode()

View File

@ -5,10 +5,10 @@ import voluptuous as vol
from ..const import ( from ..const import (
ATTR_REPOSITORIES, ATTR_ADDONS, ATTR_NAME, ATTR_SLUG, ATTR_DATE, ATTR_REPOSITORIES, ATTR_ADDONS, ATTR_NAME, ATTR_SLUG, ATTR_DATE,
ATTR_VERSION, ATTR_HOMEASSISTANT, ATTR_FOLDERS, ATTR_TYPE, ATTR_IMAGE, ATTR_VERSION, ATTR_HOMEASSISTANT, ATTR_FOLDERS, ATTR_TYPE, ATTR_IMAGE,
ATTR_PASSWORD, ATTR_PORT, ATTR_SSL, ATTR_WATCHDOG, ATTR_BOOT, ATTR_PASSWORD, ATTR_PORT, ATTR_SSL, ATTR_WATCHDOG, ATTR_BOOT, ATTR_SIZE,
ATTR_LAST_VERSION, ATTR_STARTUP_TIME, ATTR_LAST_VERSION, ATTR_WAIT_BOOT, ATTR_PROTECTED, ATTR_CRYPTO,
FOLDER_SHARE, FOLDER_HOMEASSISTANT, FOLDER_ADDONS, FOLDER_SSL, 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 from ..validate import NETWORK_PORT, REPOSITORIES, DOCKER_IMAGE
ALL_FOLDERS = [FOLDER_HOMEASSISTANT, FOLDER_SHARE, FOLDER_ADDONS, FOLDER_SSL] 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_TYPE): vol.In([SNAPSHOT_FULL, SNAPSHOT_PARTIAL]),
vol.Required(ATTR_NAME): vol.Coerce(str), vol.Required(ATTR_NAME): vol.Coerce(str),
vol.Required(ATTR_DATE): 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.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_IMAGE, 'custom_hass'): DOCKER_IMAGE,
vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'): vol.Coerce(str), vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'): vol.Coerce(str),
vol.Optional(ATTR_BOOT, default=True): vol.Boolean(), 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_PORT, default=8123): NETWORK_PORT,
vol.Optional(ATTR_PASSWORD): vol.Any(None, vol.Coerce(str)), vol.Optional(ATTR_PASSWORD): vol.Any(None, vol.Coerce(str)),
vol.Optional(ATTR_WATCHDOG, default=True): 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)), vol.All(vol.Coerce(int), vol.Range(min=60)),
}, extra=vol.REMOVE_EXTRA), }, extra=vol.REMOVE_EXTRA),
vol.Optional(ATTR_FOLDERS, default=list): vol.Optional(ATTR_FOLDERS, default=list):
@ -47,6 +50,7 @@ SCHEMA_SNAPSHOT = vol.Schema({
vol.Required(ATTR_SLUG): vol.Coerce(str), vol.Required(ATTR_SLUG): vol.Coerce(str),
vol.Required(ATTR_NAME): vol.Coerce(str), vol.Required(ATTR_NAME): vol.Coerce(str),
vol.Required(ATTR_VERSION): 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), }, extra=vol.REMOVE_EXTRA)], unique_addons),
vol.Optional(ATTR_REPOSITORIES, default=list): REPOSITORIES, vol.Optional(ATTR_REPOSITORIES, default=list): REPOSITORIES,
}, extra=vol.ALLOW_EXTRA) }, extra=vol.ALLOW_EXTRA)

78
hassio/utils/tar.py Normal file
View File

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

View File

@ -8,8 +8,7 @@ from .const import (
ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_BETA_CHANNEL, ATTR_TIMEZONE, ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_BETA_CHANNEL, ATTR_TIMEZONE,
ATTR_ADDONS_CUSTOM_LIST, ATTR_AUDIO_OUTPUT, ATTR_AUDIO_INPUT, ATTR_ADDONS_CUSTOM_LIST, ATTR_AUDIO_OUTPUT, ATTR_AUDIO_INPUT,
ATTR_PASSWORD, ATTR_HOMEASSISTANT, ATTR_HASSIO, ATTR_BOOT, ATTR_LAST_BOOT, ATTR_PASSWORD, ATTR_HOMEASSISTANT, ATTR_HASSIO, ATTR_BOOT, ATTR_LAST_BOOT,
ATTR_SSL, ATTR_PORT, ATTR_WATCHDOG, ATTR_WAIT_BOOT, ATTR_UUID, ATTR_SSL, ATTR_PORT, ATTR_WATCHDOG, ATTR_WAIT_BOOT, ATTR_UUID)
ATTR_STARTUP_TIME)
NETWORK_PORT = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)) 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_PASSWORD): vol.Any(None, vol.Coerce(str)),
vol.Optional(ATTR_SSL, default=False): vol.Boolean(), vol.Optional(ATTR_SSL, default=False): vol.Boolean(),
vol.Optional(ATTR_WATCHDOG, default=True): 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)), vol.All(vol.Coerce(int), vol.Range(min=60)),
}, extra=vol.REMOVE_EXTRA) }, extra=vol.REMOVE_EXTRA)

View File

@ -44,9 +44,10 @@ setup(
'aiohttp==2.3.10', 'aiohttp==2.3.10',
'docker==3.0.1', 'docker==3.0.1',
'colorlog==3.1.2', 'colorlog==3.1.2',
'voluptuous==0.10.5', 'voluptuous==0.11.1',
'gitpython==2.1.8', 'gitpython==2.1.8',
'pytz==2018.3', 'pytz==2018.3',
'pyudev==0.21.0' 'pyudev==0.21.0',
'pycryptodome==3.4.11'
] ]
) )