# -*- coding: utf-8 -*- import hashlib import json import os import requests import shutil import subprocess import time import uuid from cryptography.exceptions import InvalidSignature from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.serialization import load_pem_public_key from threading import Lock from . import tools PUB_FILE = '/srv/vm/packages.pub' LXC_ROOT = '/var/lib/lxc' class ActionItem: def __init__(self, action, app): self.timestamp = int(time.time()) self.action = action self.app = app self.started = False self.finished = False self.data = None class InstallItem: def __init__(self, total): # Stage 0 = download, 1 = deps install, 2 = app install self.stage = 0 self.total = total self.downloaded = 0 def __str__(self): # Limit the disaplyed percentage between 1 - 99 for aestethical and psychological reasons return str(max(1, min(99, round(self.downloaded / self.total * 100)))) class AppMgr: def __init__(self, vmmgr): self.vmmgr = vmmgr self.conf = vmmgr.conf self.online_packages = {} self.action_queue = {} self.lock = Lock() def get_repo_resource(self, url, stream=False): return requests.get('{}/{}'.format(self.conf['repo']['url'], url), auth=(self.conf['repo']['user'], self.conf['repo']['pwd']), stream=stream) def fetch_online_packages(self): # Fetches and verifies online packages. Can raise InvalidSignature online_packages = {} packages = self.get_repo_resource('packages').content packages_sig = self.get_repo_resource('packages.sig').content with open(PUB_FILE, 'rb') as f: pub_key = load_pem_public_key(f.read(), default_backend()) pub_key.verify(packages_sig, packages, ec.ECDSA(hashes.SHA512())) online_packages = json.loads(packages) # Minimze the time when self.online_packages is out of sync self.online_packages = online_packages def enqueue_action(self, action, app): # Remove actions older than 1 day for id,item in self.action_queue.items(): if item.timestamp < time.time() - 86400: del self.item[id] # Enqueue action id = '{}:{}'.format(app, uuid.uuid4()) item = ActionItem(action, app) self.action_queue[id] = item return id,item def get_actions(self, ids): # Return list of requested actions result = {} for id in ids: result[id] = self.action_queue[id] if id in self.action_queue else None return result def process_action(self, id): # Main method for deferred queue processing called by WSGI close handler item = self.action_queue[id] with self.lock: item.started = True try: # Call the action method inside exclusive lock getattr(self, item.action)(item) except BaseException as e: item.data = e finally: item.finished = True def start_app(self, item): if not tools.is_service_started(item.app): self.vmmgr.start_app(item.app) def stop_app(self, item): if tools.is_service_started(item.app): self.vmmgr.stop_app(item.app) def install_app(self, item): # Main installation function. Wrapper for download, registration and install script deps = [d for d in self.get_install_deps(item.app) if d not in self.conf['packages']] item.data = InstallItem(sum(self.online_packages[d]['size'] for d in deps)) for dep in deps: self.download_package(dep, item.data) for dep in deps: item.data.stage = 2 if dep == deps[-1] else 1 # Purge old data before unpacking to clean previous failed installation self.purge_package(dep) self.unpack_package(dep) # Run uninstall script before installation to clean previous failed installation self.run_uninstall_script(dep) self.register_package(dep) self.run_install_script(dep) def uninstall_app(self, item): # Main uninstallation function. Wrapper for uninstall script, filesystem purge and unregistration self.stop_app(item) if tools.is_service_autostarted(item.app): self.vmmgr.disable_autostart(item.app) deps = self.get_install_deps(item.app, False)[::-1] for dep in deps: if dep not in self.get_uninstall_deps(): self.run_uninstall_script(dep) self.purge_package(dep) self.unregister_package(dep) def download_package(self, name, installitem): tmp_archive = '/tmp/{}.tar.xz'.format(name) r = self.get_repo_resource('{}.tar.xz'.format(name), True) with open(tmp_archive, 'wb') as f: for chunk in r.iter_content(chunk_size=65536): if chunk: installitem.downloaded += f.write(chunk) def unpack_package(self, name): tmp_archive = '/tmp/{}.tar.xz'.format(name) # Verify hash if self.online_packages[name]['sha512'] != hash_file(tmp_archive): raise InvalidSignature(name) # Unpack subprocess.run(['tar', 'xJf', tmp_archive], cwd='/', check=True) os.unlink(tmp_archive) def purge_package(self, name): # Removes package and shared data from filesystem lxcpath = self.conf['packages'][name]['lxcpath'] if name in self.conf['packages'] else self.online_packages[name]['lxcpath'] lxc_dir = os.path.join(LXC_ROOT, lxcpath) if os.path.exists(lxc_dir): shutil.rmtree(lxc_dir) srv_dir = os.path.join('/srv/', name) if os.path.exists(srv_dir): shutil.rmtree(srv_dir) lxc_log = '/var/log/lxc/{}.log'.format(name) if os.path.exists(lxc_log): os.unlink(lxc_log) def register_package(self, name): # Registers a package in local configuration metadata = self.online_packages[name] self.conf['packages'][name] = { 'deps': metadata['deps'], 'lxcpath': metadata['lxcpath'], 'version': metadata['version'] } # If host definition is present, register the package as application if 'host' in metadata: self.conf['apps'][name] = { 'title': metadata['title'], 'host': metadata['host'], 'login': 'N/A', 'password': 'N/A', 'visible': False } self.conf.save() def unregister_package(self, name): # Removes a package from local configuration del self.conf['packages'][name] if name in self.conf['apps']: del self.conf['apps'][name] self.conf.save() def run_install_script(self, name): # Runs install.sh for a package, if the script is present install_dir = os.path.join('/srv/', name, 'install') install_script = os.path.join('/srv/', name, 'install.sh') if os.path.exists(install_script): subprocess.run(install_script, check=True) os.unlink(install_script) if os.path.exists(install_dir): shutil.rmtree(install_dir) def run_uninstall_script(self, name): # Runs uninstall.sh for a package, if the script is present uninstall_script = os.path.join('/srv/', name, 'uninstall.sh') if os.path.exists(uninstall_script): subprocess.run(uninstall_script, check=True) def get_install_deps(self, name, online=True): # Flatten dependency tree for a package while preserving the dependency order packages = self.online_packages if online else self.conf['packages'] deps = packages[name]['deps'].copy() for dep in deps[::-1]: deps[:0] = [d for d in self.get_install_deps(dep, online)] deps = list(dict.fromkeys(deps + [name])) return deps def get_uninstall_deps(self): # Create reverse dependency tree for all installed packages deps = {} for pkg in self.conf['packages']: for d in self.conf['packages'][pkg]['deps']: deps.setdefault(d, []).append(pkg) return deps def hash_file(file_path): sha512 = hashlib.sha512() with open(file_path, 'rb') as f: while True: data = f.read(65536) if not data: break sha512.update(data) return sha512.hexdigest()