diff --git a/usr/lib/python3.8/vmmgr/actionqueue.py b/usr/lib/python3.8/vmmgr/actionqueue.py index 1d67578..9cdd23e 100644 --- a/usr/lib/python3.8/vmmgr/actionqueue.py +++ b/usr/lib/python3.8/vmmgr/actionqueue.py @@ -1,14 +1,69 @@ # -*- coding: utf-8 -*- +from enum import Enum from collections import deque from threading import Lock +from spoc.config import LOCK_FILE +from spoc.flock import locked + +class ActionItemType(Enum): + IMAGE_DOWNLOAD = 1 + IMAGE_UNPACK = 2 + IMAGE_DELETE = 3 + APP_DOWNLOAD = 4 + APP_UNPACK = 5 + APP_INSTALL = 6 + APP_UPDATE = 7 + APP_UNINSTALL = 8 class ActionItem: - def __init__(self, key, action): + def __init__(self, type, key, action, show_progress=True): + self.type = type self.key = key self.action = action + self.show_progress = show_progress + self.units_total = 1 + self.units_done = 0 + + def run(self): + if self.show_progress: + self.action(self) + else: + self.action() + self.units_done = 1 + +class ActionAppQueue: + def __init__(self, action): + self.action = action + self.queue = [] self.started = False - self.data = None + self.exception = None + self.index = 0 + + def download_image(self, image): + self.queue.append(ActionItem(ActionItemType.IMAGE_DOWNLOAD, image.name, image.download)) + self.queue.append(ActionItem(ActionItemType.IMAGE_UNPACK, image.name, image.unpack_downloaded)) + + def delete_image(self, image): + self.queue.append(ActionItem(ActionItemType.IMAGE_DELETE, image.name, image.delete, False)) + + def install_app(self, app): + self.queue.append(ActionItem(ActionItemType.APP_DOWNLOAD, app.name, app.download)) + self.queue.append(ActionItem(ActionItemType.APP_UNPACK, app.name, app.unpack_downloaded)) + self.queue.append(ActionItem(ActionItemType.APP_INSTALL, app.name, app.install, False)) + + def update_app(self, app): + self.queue.append(ActionItem(ActionItemType.APP_DOWNLOAD, app.name, app.download)) + self.queue.append(ActionItem(ActionItemType.APP_UNPACK, app.name, app.unpack_downloaded)) + self.queue.append(ActionItem(ActionItemType.APP_UPDATE, app.name, app.update, False)) + + def uninstall_app(self, app): + self.queue.append(ActionItem(ActionItemType.APP_UNINSTALL, app.name, app.uninstall, False)) + + def process(self): + for item in self.queue: + self.index += 1 + item.run() class ActionQueue: def __init__(self): @@ -18,20 +73,21 @@ class ActionQueue: self.is_running = False def get_actions(self): - # Return copy of actions, so they can be traversed without state changes + # Return copy of actions, so they can be read and traversed without state changes with self.lock: return self.actions.copy() - def enqueue_action(self, key, action): + def enqueue_action(self, app_name, action): # Enqueue action with self.lock: - if key in self.actions: - # If the key alredy has a pending action, reject any other actions + if app_name in self.actions: + # If the app already has a pending action, reject any other actions return - item = ActionItem(key, action) - self.actions[key] = item - self.queue.append(item) + # Create empty queue to be populated with actions just before execution + self.actions[app_name] = ActionAppQueue(action) + self.queue.append(app_name) + @locked(LOCK_FILE) def process_actions(self): # Main method for deferred queue processing called by WSGI close handler with self.lock: @@ -41,28 +97,32 @@ class ActionQueue: while True: with self.lock: # Try to get an item from queue - item = None + app_name = None if self.queue: - item = self.queue.popleft() + app_name = self.queue.popleft() # If there are no more queued items, unset the processing flag and allow the thread to be terminated - if not item: + if not app_name: self.is_running = False return # If there is an item to be processed, set processing flags and exit the lock self.is_running = True - item.started = True + app_queue = self.actions[app_name] try: - # Call the method passed in item.action with the whole item as parameter - item.action(item) - # If the action finished without errors, restore nominal state by deleting the item from action list - self.clear_action(item.key) + # Call the method passed in app_queue.action to populate the queue of the actual actions to be taken in relation to the current local repository state + app_queue.action(app_name, app_queue) + # Process the freshly populated queue of actions related to the particular app + app_queue.process() + # If the actions finished without errors, restore nominal state by deleting the item from action list + self.clear_action(app_name) except BaseException as e: # If the action failed, store the exception and leave it in the list for manual clearance with self.lock: - item.data = e + app_queue.exception = e - def clear_action(self, key): + def clear_action(self, app_name): # Restore nominal state by deleting the item from action list with self.lock: - if key in self.actions: - del self.actions[key] + try: + del self.actions[app_name] + except KeyError: + pass diff --git a/usr/lib/python3.8/vmmgr/appmgr.py b/usr/lib/python3.8/vmmgr/appmgr.py deleted file mode 100644 index f23f11f..0000000 --- a/usr/lib/python3.8/vmmgr/appmgr.py +++ /dev/null @@ -1,100 +0,0 @@ -# -*- coding: utf-8 -*- - -import os -import subprocess - -#from .pkgmgr import Pkg, PkgMgr - -class AppMgr: - def __init__(self, conf): - self.conf = conf - self.pkgmgr = None #PkgMgr(conf) - - def start_app(self, item): - # Start the actual app service - app = item.key - if app in self.conf['apps'] and not self.is_service_started(app): - self.start_service(app) - - def start_service(self, service): - subprocess.run(['/sbin/service', service, 'start'], check=True) - - def stop_app(self, item): - # Stop the actual app service - app = item.key - if app in self.conf['apps'] and self.is_service_started(app): - self.stop_service(app) - # Stop the app service's dependencies if they are not used by another running app - deps = self.get_services_deps() - for dep in self.get_service_deps(app): - if not any([self.is_service_started(d) for d in deps[dep]]): - self.stop_service(dep) - - def stop_service(self, service): - subprocess.run(['/sbin/service', service, 'stop'], check=True) - - def update_app_visibility(self, app, visible): - # Update visibility for the app in the configuration - if app in self.conf['apps']: - self.conf['apps'][app]['visible'] = visible - self.conf.save() - - def update_app_autostart(self, app, enabled): - # Add/remove the app to OpenRC default runlevel - if app in self.conf['apps']: - subprocess.run(['/sbin/rc-update', 'add' if enabled else 'del', app]) - - def is_service_started(self, app): - # Check OpenRC service status without calling any binary - return os.path.exists(os.path.join('/run/openrc/started', app)) - - def is_service_autostarted(self, app): - # Check OpenRC service enablement - return os.path.exists(os.path.join('/etc/runlevels/default', app)) - - def install_app(self, item): - # Main installation function. Wrapper for download, registration and install script - item.data = None #Pkg() - self.pkgmgr.install_app(item.key, item.data) - - def uninstall_app(self, item): - # Main uninstallation function. Wrapper for uninstall script, filesystem purge and unregistration - app = item.key - self.stop_app(item) - if self.is_service_autostarted(app): - self.update_app_autostart(app, False) - self.pkgmgr.uninstall_app(app) - - def update_app(self, item): - # Main update function. Wrapper for download and update script - self.stop_app(item) - item.data = None #Pkg() - self.pkgmgr.update_app(item.key, item.data) - - def get_services_deps(self): - # Fisrt, build a dictionary of {app: [needs]} - needs = {} - for app in self.conf['apps'].copy(): - needs[app] = self.get_service_deps(app) - # Then reverse it to {need: [apps]} - deps = {} - for app, need in needs.items(): - for n in need: - deps.setdefault(n, []).append(app) - return deps - - def get_service_deps(self, app): - # Get "need" line from init script and split it to a list - try: - with open(os.path.join('/etc/init.d', app), 'r') as f: - return [l for l in f.readlines() if l.strip().startswith('need')][0].split()[1:] - except: - pass - return [] - - def update_repo_settings(self, url, user, pwd): - # Update lxc repository configuration - self.conf['repo']['url'] = url - self.conf['repo']['user'] = user - self.conf['repo']['pwd'] = pwd - self.conf.save() diff --git a/usr/lib/python3.8/vmmgr/config.py b/usr/lib/python3.8/vmmgr/config.py index c9f8919..e629e92 100644 --- a/usr/lib/python3.8/vmmgr/config.py +++ b/usr/lib/python3.8/vmmgr/config.py @@ -1,27 +1,83 @@ # -*- coding: utf-8 -*- -import fcntl import json +import os +from spoc.flock import locked from .paths import CONF_FILE, CONF_LOCK -class Config: - def __init__(self): - self.load() +data = {} +mtime = None - def load(self): - # Load configuration from file. Uses file lock as interprocess mutex - with open(CONF_LOCK, 'w') as lock: - fcntl.lockf(lock, fcntl.LOCK_EX) - with open(CONF_FILE, 'r') as f: - self.data = json.load(f) +def load(): + global data + global mtime + file_mtime = os.stat(CONF_FILE).st_mtime + if mtime != file_mtime: + with open(CONF_FILE, 'r') as f: + data = json.load(f) + mtime = file_mtime - def save(self): - # Save configuration to a file. Uses file lock as interprocess mutex - with open(CONF_LOCK, 'w') as lock: - fcntl.lockf(lock, fcntl.LOCK_EX) - with open(CONF_FILE, 'w') as f: - json.dump(self.data, f, sort_keys=True, indent=4) +def save(): + global mtime + with open(CONF_FILE, 'w') as f: + json.dump(data, f, sort_keys=True, indent=4) + mtime = os.stat(CONF_FILE).st_mtime - def __getitem__(self, attr): - return self.data[attr] +@locked(CONF_LOCK) +def get_entries(attr): + load() + return data[attr] + +@locked(CONF_LOCK) +def add_entry(entry_type, name, definition): + load() + data[entry_type][name] = definition + save() + +@locked(CONF_LOCK) +def delete_entry(entry_type, name): + load() + try: + del data[entry_type][name] + save() + except KeyError: + pass + +def get_apps(): + return get_entries('apps') + +def get_common(): + return get_entries('common') + +def get_host(): + host = get_entries('host') + return (host['domain'], host['port']) + +def get_adminpwd(): + return get_entries('host')['adminpwd'] + +def register_app(app_name, definition): + add_entry('apps', app_name, definition) + +def unregister_app(app_name): + delete_entry('apps', app_name) + +def set_common(key, value): + add_entry('common', key, value) + +@locked(CONF_LOCK) +def set_host(domain, port): + load() + data['host']['domain'] = domain + data['host']['port'] = port + save() + +def set_adminpwd(hash): + add_entry('host', 'adminpwd', hash) + +@locked(CONF_LOCK) +def set_app(app_name, key, value): + load() + data['apps'][app_name][key] = value + save() diff --git a/usr/lib/python3.8/vmmgr/crypto.py b/usr/lib/python3.8/vmmgr/crypto.py index 38ae501..57f4751 100644 --- a/usr/lib/python3.8/vmmgr/crypto.py +++ b/usr/lib/python3.8/vmmgr/crypto.py @@ -3,17 +3,18 @@ import bcrypt import datetime import os - from cryptography import x509 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import ec from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID +from . import config from .paths import ACME_CRON, CERT_PUB_FILE, CERT_KEY_FILE -def create_selfsigned_cert(domain): +def create_selfsigned_cert(): # Create selfsigned certificate with wildcard alternative subject name + domain = config.get_host()[0] private_key = ec.generate_private_key(ec.SECP384R1(), default_backend()) public_key = private_key.public_key() subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, domain)]) @@ -25,7 +26,7 @@ def create_selfsigned_cert(domain): .serial_number(x509.random_serial_number()) \ .not_valid_before(now) \ .not_valid_after(now + datetime.timedelta(days=7305)) \ - .add_extension(x509.SubjectAlternativeName((x509.DNSName(domain), x509.DNSName('*.{}'.format(domain)))), critical=False) \ + .add_extension(x509.SubjectAlternativeName((x509.DNSName(domain), x509.DNSName(f'*.{domain}'))), critical=False) \ .add_extension(x509.SubjectKeyIdentifier.from_public_key(public_key), critical=False) \ .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(public_key), critical=False) \ .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) \ @@ -44,7 +45,7 @@ def get_cert_info(): cert = x509.load_pem_x509_certificate(f.read(), default_backend()) data = {'subject': cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value, 'issuer': cert.issuer.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value, - 'expires': '{} UTC'.format(cert.not_valid_after), + 'expires': f'{cert.not_valid_after} UTC', 'method': 'manual'} if os.access(ACME_CRON, os.X_OK): data['method'] = 'automatic' @@ -58,5 +59,5 @@ def get_cert_info(): def adminpwd_hash(password): return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() -def adminpwd_verify(password, pwhash): - return bcrypt.checkpw(password.encode(), pwhash.encode()) +def adminpwd_verify(password): + return bcrypt.checkpw(password.encode(), config.get_adminpwd().encode()) diff --git a/usr/lib/python3.8/vmmgr/net.py b/usr/lib/python3.8/vmmgr/net.py index f4fd449..2c9ae41 100644 --- a/usr/lib/python3.8/vmmgr/net.py +++ b/usr/lib/python3.8/vmmgr/net.py @@ -9,8 +9,8 @@ import subprocess from .paths import MYIP_URL, PING_URL def compile_url(domain, port, proto='https'): - port = '' if (proto == 'https' and port == '443') or (proto == 'http' and port == '80') else ':{}'.format(port) - return '{}://{}{}'.format(proto, domain, port) + port = '' if (proto in (None, 'https') and port == '443') or (proto == 'http' and port == '80') else f':{port}' + return f'{proto}://{domain}{port}' if proto else f'{domain}{port}' def get_local_ip(version=None): # Return first routable IPv4/6 address of the VM (container host) diff --git a/usr/lib/python3.8/vmmgr/paths.py b/usr/lib/python3.8/vmmgr/paths.py index 6187d75..4866ddf 100644 --- a/usr/lib/python3.8/vmmgr/paths.py +++ b/usr/lib/python3.8/vmmgr/paths.py @@ -24,4 +24,3 @@ WG_CONF_FILE_DISABLED = '/etc/wireguard/wg0.conf.disabled' # URLs MYIP_URL = 'https://repo.spotter.cz/tools/myip.php' PING_URL = 'https://repo.spotter.cz/tools/vm-ping.php' -RELOAD_URL = 'http://127.0.0.1:8080/reload-config' diff --git a/usr/lib/python3.8/vmmgr/remote.py b/usr/lib/python3.8/vmmgr/remote.py index bb304e1..8c79c22 100644 --- a/usr/lib/python3.8/vmmgr/remote.py +++ b/usr/lib/python3.8/vmmgr/remote.py @@ -1,10 +1,8 @@ # -*- coding: utf-8 -*- -import configparser import os import subprocess -from . import templates from .paths import AUTHORIZED_KEYS, INTERFACES_FILE, WG_CONF_FILE, WG_CONF_FILE_DISABLED def get_authorized_keys(): @@ -38,7 +36,7 @@ def regenerate_wireguard_key(): privkey = subprocess.run(['wg', 'genkey'], stdout=subprocess.PIPE).stdout.strip().decode() with open(WG_CONF_FILE_DISABLED) as f: conf_lines = f.readlines() - conf_lines[2] = 'PrivateKey = {}\n'.format(privkey) + conf_lines[2] = f'PrivateKey = {privkey}\n' with open(WG_CONF_FILE_DISABLED, 'w') as f: f.writelines(conf_lines) if was_running: @@ -77,14 +75,14 @@ def set_wireguard_conf(ip, port, peers): with open(INTERFACES_FILE) as f: for line in f.readlines(): if '172.17.255' in line: - line = ' address 172.17.255.{}\n'.format(ip) + line = f' address 172.17.255.{ip}\n' interface_lines.append(line) with open(INTERFACES_FILE, 'w') as f: f.writelines(interface_lines) # Recreate config (listen port and peers) with open(WG_CONF_FILE_DISABLED) as f: conf_lines = f.readlines()[:4] - conf_lines[1] = 'ListenPort = {}\n'.format(port) + conf_lines[1] = f'ListenPort = {port}\n' with open(WG_CONF_FILE_DISABLED, 'w') as f: f.writelines(conf_lines) f.write(peers) diff --git a/usr/lib/python3.8/vmmgr/vmmgr.py b/usr/lib/python3.8/vmmgr/vmmgr.py index e066b4f..275b2de 100644 --- a/usr/lib/python3.8/vmmgr/vmmgr.py +++ b/usr/lib/python3.8/vmmgr/vmmgr.py @@ -1,149 +1,212 @@ # -*- coding: utf-8 -*- -import json +import configparser import os -import requests import shutil import subprocess import urllib +from spoc.app import App +from spoc.config import ONLINE_BASE_URL +from spoc.container import Container, ContainerState -from . import crypto -from . import templates -from . import net -from .paths import ACME_CRON, ACME_DIR, ISSUE_FILE, MOTD_FILE, NGINX_DIR, RELOAD_URL +from . import crypto, net, templates +from .paths import ACME_CRON, ACME_DIR, ISSUE_FILE, MOTD_FILE, NGINX_DIR -class VMMgr: - def __init__(self, conf): - # Load JSON configuration - self.conf = conf - self.domain = conf['host']['domain'] - self.port = conf['host']['port'] +def register_app(app, host, login, password): + # Register newly installed application, its subdomain and credentials (called at the end of package install.sh) + config.register_app(app, { + 'host': host, + 'login': login if login else 'N/A', + 'password': password if password else 'N/A', + 'visible': False, + }) - def register_app(self, app, host, login, password): - # Register newly installed application, its subdomain and credentials (called at the end of package install.sh) - self.conf['apps'][app] = {'host': host, - 'login': login if login else 'N/A', - 'password': password if password else 'N/A', - 'visible': False} - self.conf.save() - self.reload_wsgi_config() +def unregister_app(app): + config.unregister_app(app) - def unregister_app(self, app): - # Unregister application during uninstallation (called at the end of package uninstall.sh) - if app not in self.conf['apps']: - return - del self.conf['apps'][app] - self.conf.save() - self.reload_wsgi_config() +def register_proxy(app): + # Setup proxy configuration and reload nginx + app_host = config.get_app(app)['host'] + domain,port = config.get_host() + with open(os.path.join(NGINX_DIR, f'{app}.conf'), 'w') as f: + f.write(templates.NGINX.format(app=app, host=app_host, domain=domain, port=port)) + reload_nginx() - def reload_wsgi_config(self): - # Attempt to contact running vmmgr WSGI application to reload config +def unregister_proxy(app): + # Remove proxy configuration and reload nginx + try: + os.unlink(os.path.join(NGINX_DIR, f'{app}.conf')) + reload_nginx() + except FileNotFoundError: + pass + +def update_host(domain, port): + config.set_host(domain, port) + # Rebuild nginx config for the portal app. Web interface calls restart_nginx() in WSGI close handler + with open(os.path.join(NGINX_DIR, 'default.conf'), 'w') as f: + f.write(templates.NGINX_DEFAULT.format(port=port, domain_esc=domain.replace('.', '\\.'))) + +def reload_nginx(): + subprocess.run(['/usr/sbin/nginx', '-s', 'reload']) + +def restart_nginx(): + subprocess.run(['/sbin/service', 'nginx', 'restart']) + +def rebuild_issue(): + # Compile the URLs displayed in terminal banner and rebuild the issue and motd files + domain, port = config.get_host() + issue = templates.ISSUE.format(url=net.compile_url(domain, port), ip=net.compile_url(net.get_local_ip(), port)) + with open(ISSUE_FILE, 'w') as f: + f.write(issue) + with open(MOTD_FILE, 'w') as f: + f.write(issue) + +def update_common_settings(email, gmaps_api_key): + # Update common configuration values + config.set_common('email', email) + config.set_common('gmaps-api-key', gmaps_api_key) + +def update_password(oldpassword, newpassword): + # Update LUKS password and adminpwd for WSGI application + pwinput = f'{oldpassword}\n{newpassword}'.encode() + partition_uuid = open('/etc/crypttab').read().split()[1][5:] + partition_name = subprocess.run(['/sbin/blkid', '-U', partition_uuid], check=True, stdout=subprocess.PIPE).stdout.decode().strip() + subprocess.run(['cryptsetup', 'luksChangeKey', partition_name], input=pwinput, check=True) + # Update bcrypt-hashed password in config + config.set_adminpwd(crypto.adminpwd_hash(newpassword)) + +def create_selfsigned_cert(): + # Disable acme.sh cronjob + os.chmod(ACME_CRON, 0o640) + # Create selfsigned certificate with wildcard alternative subject name + domain = config.get_host()[0] + crypto.create_selfsigned_cert(domain) + # Reload nginx + reload_nginx() + +def request_acme_cert(): + # Remove all possible conflicting certificates requested in the past + domain = config.get_host()[0] + certs = [i for i in os.listdir(ACME_DIR) if i not in ('account.conf', 'ca', 'http.header')] + for cert in certs: + if cert != domain: + subprocess.run(['/usr/bin/acme.sh', '--home', '/etc/acme.sh.d', '--remove', '-d', cert]) + # Compile an acme.sh command for certificate requisition only if the certificate hasn't been requested before + if not os.path.exists(os.path.join(ACME_DIR, domain)): + cmd = ['/usr/bin/acme.sh', '--issue', '-d', domain] + for app,definition in config.get_apps(): + cmd += ['-d', f'{definition["host"]}.{domain}'] + cmd += ['-w', ACME_DIR] + # Request the certificate + subprocess.run(cmd, check=True) + # Otherwise just try to renew + else: + # Acme.sh returns code 2 on skipped renew try: - requests.get(RELOAD_URL, timeout=3) - except: - pass + subprocess.run(['/usr/bin/acme.sh', '--home', '/etc/acme.sh.d', '--renew', '-d', domain], check=True) + except subprocess.CalledProcessError as e: + if e.returncode != 2: + raise + # Install the issued certificate + subprocess.run(['/usr/bin/acme.sh', '--home', '/etc/acme.sh.d', '--install-cert', '-d', domain, '--key-file', crypto.CERT_KEY_FILE, '--fullchain-file', crypto.CERT_PUB_FILE, '--reloadcmd', '/sbin/service nginx reload'], check=True) + # Enable acme.sh cronjob + os.chmod(ACME_CRON, 0o750) - def register_proxy(self, app): - # Setup proxy configuration and reload nginx - with open(os.path.join(NGINX_DIR, '{}.conf'.format(app)), 'w') as f: - f.write(templates.NGINX.format(app=app, host=self.conf['apps'][app]['host'], domain=self.conf['host']['domain'], port=self.conf['host']['port'])) - self.reload_nginx() +def install_manual_cert(public_file, private_file): + # Disable acme.sh cronjob + os.chmod(ACME_CRON, 0o640) + # Copy certificate files + shutil.copyfile(public_file, crypto.CERT_PUB_FILE) + shutil.copyfile(private_file, crypto.CERT_KEY_FILE) + os.chmod(crypto.CERT_KEY_FILE, 0o600) + # Reload nginx + reload_nginx() - def unregister_proxy(self, app): - # Remove proxy configuration and reload nginx - try: - os.unlink(os.path.join(NGINX_DIR, '{}.conf'.format(app))) - self.reload_nginx() - except FileNotFoundError: - pass +def shutdown_vm(): + subprocess.run(['/sbin/poweroff']) - def update_host(self, domain, port): - # Update domain and port, rebuild all configuration and restart nginx - self.domain = self.conf['host']['domain'] = domain - self.port = self.conf['host']['port'] = port - self.conf.save() - # Rebuild nginx config for the portal app. Web interface calls restart_nginx() in WSGI close handler - with open(os.path.join(NGINX_DIR, 'default.conf'), 'w') as f: - f.write(templates.NGINX_DEFAULT.format(port=self.port, domain_esc=self.domain.replace('.', '\\.'))) +def reboot_vm(): + subprocess.run(['/sbin/reboot']) - def reload_nginx(self): - subprocess.run(['/usr/sbin/nginx', '-s', 'reload']) +def start_app(item): + # Start the actual app service + app = item.key + if app in config.get_apps() and not is_app_started(app): + start_service(app) - def restart_nginx(self): - subprocess.run(['/sbin/service', 'nginx', 'restart']) +def start_service(service): + subprocess.run(['/sbin/service', service, 'start'], check=True) - def rebuild_issue(self): - # Compile the URLs displayed in terminal banner and rebuild the issue and motd files - issue = templates.ISSUE.format(url=net.compile_url(self.domain, self.port), ip=net.compile_url(net.get_local_ip(), self.port)) - with open(ISSUE_FILE, 'w') as f: - f.write(issue) - with open(MOTD_FILE, 'w') as f: - f.write(issue) +def stop_app(item): + # Stop the actual app service + app = item.key + if app in config.get_apps() and is_app_started(app): + stop_service(app) + # Stop the app service's dependencies if they are not used by another running app + deps = get_services_deps() + for dep in get_service_deps(app): + if not any([is_app_started(d) for d in deps[dep]]): + stop_service(dep) - def update_common_settings(self, email, gmaps_api_key): - # Update common configuration values - self.conf['common']['email'] = email - self.conf['common']['gmaps-api-key'] = gmaps_api_key - self.conf.save() +def stop_service(service): + subprocess.run(['/sbin/service', service, 'stop'], check=True) - def update_password(self, oldpassword, newpassword): - # Update LUKS password and adminpwd for WSGI application - pwinput = '{}\n{}'.format(oldpassword, newpassword).encode() - partition_uuid = open('/etc/crypttab').read().split()[1][5:] - partition_name = subprocess.run(['/sbin/blkid', '-U', partition_uuid], check=True, stdout=subprocess.PIPE).stdout.decode().strip() - subprocess.run(['cryptsetup', 'luksChangeKey', partition_name], input=pwinput, check=True) - # Update bcrypt-hashed password in config - self.conf['host']['adminpwd'] = crypto.adminpwd_hash(newpassword) - # Save config to file - self.conf.save() +def update_app_visibility(app_name, visible): + # Update visibility for the app in the configuration + config.set_app(app_name, 'visible', visible) - def create_selfsigned_cert(self): - # Disable acme.sh cronjob - os.chmod(ACME_CRON, 0o640) - # Create selfsigned certificate with wildcard alternative subject name - crypto.create_selfsigned_cert(self.domain) - # Reload nginx - self.reload_nginx() +def update_app_autostart(app_name, enabled): + # Add/remove the app to OpenRC default runlevel + App(app_name).set_autostart(enabled) - def request_acme_cert(self): - # Remove all possible conflicting certificates requested in the past - certs = [i for i in os.listdir(ACME_DIR) if i not in ('account.conf', 'ca', 'http.header')] - for cert in certs: - if cert != self.domain: - subprocess.run(['/usr/bin/acme.sh', '--home', '/etc/acme.sh.d', '--remove', '-d', cert]) - # Compile an acme.sh command for certificate requisition only if the certificate hasn't been requested before - if not os.path.exists(os.path.join(ACME_DIR, self.domain)): - cmd = ['/usr/bin/acme.sh', '--issue', '-d', self.domain] - for app in self.conf['apps'].copy(): - cmd += ['-d', '{}.{}'.format(self.conf['apps'][app]['host'], self.domain)] - cmd += ['-w', ACME_DIR] - # Request the certificate - subprocess.run(cmd, check=True) - # Otherwise just try to renew - else: - # Acme.sh returns code 2 on skipped renew - try: - subprocess.run(['/usr/bin/acme.sh', '--home', '/etc/acme.sh.d', '--renew', '-d', self.domain], check=True) - except subprocess.CalledProcessError as e: - if e.returncode != 2: - raise - # Install the issued certificate - subprocess.run(['/usr/bin/acme.sh', '--home', '/etc/acme.sh.d', '--install-cert', '-d', self.domain, '--key-file', crypto.CERT_KEY_FILE, '--fullchain-file', crypto.CERT_PUB_FILE, '--reloadcmd', '/sbin/service nginx reload'], check=True) - # Enable acme.sh cronjob - os.chmod(ACME_CRON, 0o750) +def is_app_started(app_name): + # Assume that the main container has always the same name as app + return Container(app_name).get_status() == ContainerState.RUNNING - def install_manual_cert(self, public_file, private_file): - # Disable acme.sh cronjob - os.chmod(ACME_CRON, 0o640) - # Copy certificate files - shutil.copyfile(public_file, crypto.CERT_PUB_FILE) - shutil.copyfile(private_file, crypto.CERT_KEY_FILE) - os.chmod(crypto.CERT_KEY_FILE, 0o600) - # Reload nginx - self.reload_nginx() +def is_app_autostarted(app_name): + # Check OpenRC service enablement + return App(app_name, False).autostart - def shutdown_vm(self): - subprocess.run(['/sbin/poweroff']) +def install_app(app_name, queue): + # Main installation function. Wrapper for download, registration and install script + required_images = [] + for container in repo_online.get_app(app_name)['containers'].values(): + required_images.extend(repo_online.get_image(container['image'])['layers']) + local_images = repo_local.get_images() + for layer in set(required_images): + if layer not in local_images: + queue.download_image(Image(layer, False)) + queue.install_app(App(app_name, False, False)) - def reboot_vm(self): - subprocess.run(['/sbin/reboot']) +def uninstall_app(app_name, queue): + # Main uninstallation function. Wrapper for uninstall script, filesystem purge and unregistration + queue.uninstall_app(App(app_name, False)) + +def update_app(app_name, queue): + # Main update function. Wrapper for download and update script + required_images = [] + for container in repo_online.get_app(app_name)['containers'].values(): + required_images.extend(repo_online.get_image(container['image'])['layers']) + local_images = repo_local.get_images() + for layer in set(required_images): + if layer not in local_images: + queue.download_image(Image(layer, False)) + queue.update_app(App(app_name, False)) + +def update_repo_settings(url, username, password): + # Include credentials in the repo URL and save to SPOC config + spoc_config = configparser.ConfigParser() + spoc_config.read('/etc/spoc/spoc.conf') + parts = urllib.parse.urlsplit(url) + netloc = f'{username}:{password}@{url}' if username or password else url + url = urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment)) + spoc_config['repo']['url'] = ONLINE_BASE_URL = url + with open('/etc/spoc/spoc.conf', 'w') as f: + config.write(f) + +def get_repo_settings(): + # Parse the SPOC config repo URL and return as tuple + parts = urllib.parse.urlsplit(ONLINE_BASE_URL) + netloc = parts.netloc.split('@', 1)[1] if parts.username or parts.password else parts.netloc + url = urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment)) + return (url, parts.username, parts.password) diff --git a/usr/lib/python3.8/vmmgr/wsgiapp.py b/usr/lib/python3.8/vmmgr/wsgiapp.py index 4cef0b8..7c8ff46 100644 --- a/usr/lib/python3.8/vmmgr/wsgiapp.py +++ b/usr/lib/python3.8/vmmgr/wsgiapp.py @@ -2,24 +2,18 @@ import json import os - +from cryptography.exceptions import InvalidSignature +from jinja2 import Environment, FileSystemLoader +from math import floor +from pkg_resources import parse_version +from spoc import repo_online, repo_local from werkzeug.exceptions import HTTPException, NotFound, Unauthorized from werkzeug.routing import Map, Rule from werkzeug.utils import redirect from werkzeug.wrappers import Request, Response -from jinja2 import Environment, FileSystemLoader -from cryptography.exceptions import InvalidSignature - -from . import crypto -from . import net -from . import remote -from . import validator -from .actionqueue import ActionQueue -from .appmgr import AppMgr -from .config import Config -#from .pkgmgr import Stage -from .vmmgr import VMMgr +from . import config, crypto, net, remote, validator, vmmgr +from .actionqueue import ActionQueue, ActionItemType from .wsgilang import WSGILang from .wsgisession import WSGISession @@ -27,12 +21,8 @@ SESSION_KEY = os.urandom(26) class WSGIApp: def __init__(self): - self.conf = Config() - self.vmmgr = VMMgr(self.conf) - self.appmgr = AppMgr(self.conf) self.queue = ActionQueue() self.jinja_env = Environment(loader=FileSystemLoader('/usr/share/vmmgr/templates'), autoescape=True, lstrip_blocks=True, trim_blocks=True) - self.jinja_env.globals.update(is_app_visible=self.is_app_visible) self.url_map = Map(( Rule('/', endpoint='portal_view'), Rule('/login', methods=['GET'], endpoint='login_view'), @@ -72,9 +62,6 @@ class WSGIApp: Rule('/start-vpn', endpoint='start_vpn_action'), Rule('/stop-vpn', endpoint='stop_vpn_action'), )) - self.localhost_url_map = Map(( - Rule('/reload-config', endpoint='reload_config_action'), - )) def __call__(self, environ, start_response): return self.wsgi_app(environ, start_response) @@ -89,12 +76,7 @@ class WSGIApp: return response(environ, start_response) def dispatch_request(self, request): - if request.session['admin']: - url_map = self.admin_url_map - elif request.remote_addr in ('127.0.0.1', '::1'): - url_map = self.localhost_url_map - else: - url_map = self.url_map + url_map = self.admin_url_map if request.session['admin'] else self.url_map adapter = url_map.bind_to_environ(request.environ) try: endpoint, values = adapter.match() @@ -113,7 +95,7 @@ class WSGIApp: def render_template(self, template_name, request, **context): # Enhance context - context['conf'] = self.conf + context['config'] = config context['session'] = request.session context['lang'] = request.session.lang # Render template @@ -144,11 +126,11 @@ class WSGIApp: def login_action(self, request): password = request.form['password'] redir = request.form['redir'] - if crypto.adminpwd_verify(password, self.conf['host']['adminpwd']): + if crypto.adminpwd_verify(password): request.session['admin'] = True - return redirect('/{}'.format(redir)) - request.session['msg'] = 'login:error:{}'.format(request.session.lang.bad_password()) - return redirect('/login?redir={}'.format(redir)) if redir else redirect('/login') + return redirect(f'/{redir}') + request.session['msg'] = f'login:error:{request.session.lang.bad_password()}' + return redirect(f'/login?redir={redir}') if redir else redirect('/login') def logout_action(self, request): request.session.reset() @@ -156,10 +138,12 @@ class WSGIApp: def portal_view(self, request): # Default portal view. - host = net.compile_url(self.conf['host']['domain'], self.conf['host']['port'])[8:] + host = net.compile_url(*config.get_host(), None) + apps = config.get_apps() + visible_apps = [app for app,definition in apps.items() if definition['visible'] and vmmgr.is_app_started(app)] if request.session['admin']: - return self.render_html('portal-admin.html', request, host=host) - return self.render_html('portal-user.html', request, host=host) + return self.render_html('portal-admin.html', request, host=host, apps=apps, visible_apps=visible_apps) + return self.render_html('portal-user.html', request, host=host, apps=apps, visible_apps=visible_apps) def setup_host_view(self, request): # Host setup view. @@ -168,13 +152,17 @@ class WSGIApp: in_ipv4 = net.get_local_ip(4) in_ipv6 = net.get_local_ip(6) cert_info = crypto.get_cert_info() - return self.render_html('setup-host.html', request, ex_ipv4=ex_ipv4, ex_ipv6=ex_ipv6, in_ipv4=in_ipv4, in_ipv6=in_ipv6, cert_info=cert_info) + apps = config.get_apps() + common = config.get_common() + domain,port = config.get_host() + return self.render_html('setup-host.html', request, ex_ipv4=ex_ipv4, ex_ipv6=ex_ipv6, in_ipv4=in_ipv4, in_ipv6=in_ipv6, cert_info=cert_info, apps=apps, common=common, domain=domain, port=port) def setup_apps_view(self, request): # Application manager view. repo_error = None try: - self.appmgr.pkgmgr.fetch_online_packages() # TODO: fetch is now automatic in @property + # Populate online_repo cache or fail early when the repo can't be reached + repo_online.get_apps() except InvalidSignature: repo_error = request.session.lang.invalid_packages_signature() except Unauthorized: @@ -183,80 +171,97 @@ class WSGIApp: repo_error = request.session.lang.repo_unavailable() table = self.render_setup_apps_table(request) message = self.get_session_message(request) - return self.render_html('setup-apps.html', request, repo_error=repo_error, table=table, message=message) + repo_url, repo_user, _ = vmmgr.get_repo_settings() + common = config.get_common() + return self.render_html('setup-apps.html', request, repo_url=repo_url, repo_user=repo_user, repo_error=repo_error, table=table, message=message, common=common) def render_setup_apps_table(self, request): lang = request.session.lang - online_packages = self.appmgr.pkgmgr.online_packages + local_apps = repo_local.get_apps() + online_apps = repo_online.get_apps() + actionable_apps = sorted(set(online_apps) | set(local_apps)) pending_actions = self.queue.get_actions() - actionable_apps = sorted(set([k for k, v in online_packages.items() if 'title' in v] + list(self.conf['apps'].keys()))) app_data = {} for app in actionable_apps: - installed = app in self.conf['packages'] and app in self.conf['apps'] - title = self.conf['packages'][app]['title'] if installed else online_packages[app]['title'] - visible = self.conf['apps'][app]['visible'] if installed else False - autostarted = self.appmgr.is_service_autostarted(app) if installed else False + installed = app in local_apps + title = local_apps[app]['meta']['title'] if installed else online_apps[app]['meta']['title'] + try: + visible = local_apps[app]['visible'] + except: + visible = False + try: + autostarted = local_apps[app]['autostart'] + except: + autostarted = False if app in pending_actions: - item = pending_actions[app] - actions = '
' - if item.action == self.appmgr.start_app: - if not item.started: - status = '{} ({})'.format(lang.status_starting(), lang.status_queued()) - elif isinstance(item.data, BaseException): - status = '{} OK'.format(lang.stop_start_error()) + # Display queued or currently processed actions + app_queue = pending_actions[app] + if app_queue.index: + if app_queue.exception: + # Display failed task + if isinstance(app_queue.exception, InvalidSignature): + status = lang.repo_package_invalid_signature() + elif isinstance(app_queue.exception, NotFound): + status = lang.repo_package_missing() + elif isinstance(app_queue.exception, BaseException): + if app_queue.action in (vmmgr.start_app, vmmgr.stop_app): + status = lang.stop_start_error() + else: + status = lang.package_manager_error() + status = f'{status} OK' actions = None else: - status = lang.status_starting() - elif item.action == self.appmgr.stop_app: - if not item.started: - status = '{} ({})'.format(lang.status_stopping(), lang.status_queued()) - elif isinstance(item.data, BaseException): - status = '{} OK'.format(lang.stop_start_error()) - actions = None - else: - status = lang.status_stopping() - elif item.action == self.appmgr.install_app: - if not item.started: - status = '{} ({})'.format(lang.status_downloading(), lang.status_queued()) - elif isinstance(item.data, InvalidSignature): - status = '{} OK'.format(lang.repo_package_invalid_signature()) - actions = None - elif isinstance(item.data, NotFound): - status = '{} OK'.format(lang.repo_package_missing()) - actions = None - elif isinstance(item.data, BaseException): - status = '{} OK'.format(lang.package_manager_error()) - actions = None - else: - if item.data.stage == 0: #Stage.DOWNLOAD: - status = '{} ({} %)'.format(lang.status_downloading(), item.data.percent_processed) - elif item.data.stage == 1: #Stage.UNPACK: - status = lang.status_unpacking() - elif item.data.stage == 2: #Stage.INSTALL_DEPS: - status = lang.status_installing_deps() + # Display task/subtask progress + if app_queue.action == vmmgr.start_app: + status = lang.status_starting() + elif app_queue.action == vmmgr.stop_app: + status = lang.status_stopping() else: - status = lang.status_installing() - elif item.action == self.appmgr.uninstall_app: - if not item.started: - status = '{} ({})'.format(lang.status_uninstalling(), lang.status_queued()) - elif isinstance(item.data, BaseException): - status = '{} OK'.format(lang.package_manager_error()) - actions = None - else: - status = lang.status_uninstalling() + action_item = app_queue.queue[app_queue.index-1] + if action_item.type in (ActionItemType.IMAGE_DOWNLOAD, ActionItemType.APP_DOWNLOAD): + status = lang.status_downloading(action_item.key) + elif action_item.type in (ActionItemType.IMAGE_UNPACK, ActionItemType.APP_UNPACK): + status = lang.status_unpacking(action_item.key) + elif action_item.type == ActionItemType.IMAGE_DELETE: + status = lang.status_deleting(action_item.key) + elif action_item.type == ActionItemType.APP_INSTALL: + status = lang.status_installing(action_item.key) + elif action_item.type == ActionItemType.APP_UPDATE: + status = lang.status_updating(action_item.key) + elif action_item.type == ActionItemType.APP_UNINSTALL: + status = lang.status_uninstalling(action_item.key) + status = f'[{app_queue.index}/{len(app_queue.queue)}] {status}' + if action_item.show_progress: + status = f'{status} ({floor(current_action.units_done/current_action.units_total*100)} %)' + actions = '' + else: + # Display queued (pending, not started) task + if app_queue.action == vmmgr.start_app: + status = lang.status_starting() + elif app_queue.action == vmmgr.stop_app: + status = lang.status_stopping() + elif app_queue.action == vmmgr.install_app: + status = lang.status_installing('') + elif app_queue.action == vmmgr.uninstall_app: + status = lang.status_uninstalling('') + elif app_queue.action == vmmgr.update_app: + status = lang.status_updating('') + status = f'{status} ({lang.status_queued()})' + actions = '' else: + # Diplay apps with no queued or currently processed action if not installed: status = lang.status_not_installed() - actions = '{}'.format(lang.action_install()) + actions = f'{lang.action_install()}' else: - if self.appmgr.is_service_started(app): - status = '{}'.format(lang.status_started()) - actions = '{}'.format(lang.action_stop()) + if vmmgr.is_app_started(app): + status = f'{lang.status_started()}' + actions = f'{lang.action_stop()}' else: - status = '{}'.format(lang.status_stopped()) - actions = '{}, {}'.format(lang.action_start(), lang.action_uninstall()) - if self.appmgr.pkgmgr.has_update(app): - actions = '{}, {}'.format(actions, lang.action_update()) + status = f'{lang.status_stopped()}' + actions = f'{lang.action_start()}, {lang.action_uninstall()}' + if parse_version(online_apps[app]['version']) > parse_version(app.version): + actions = f'{actions}, {lang.action_update()}' app_data[app] = {'title': title, 'visible': visible, 'installed': installed, 'autostarted': autostarted, 'status': status, 'actions': actions} return self.render_template('setup-apps-table.html', request, app_data=app_data) @@ -276,15 +281,16 @@ class WSGIApp: return self.render_json({'error': request.session.lang.invalid_domain()}) if not validator.is_valid_port(port): return self.render_json({'error': request.session.lang.invalid_port()}) - self.vmmgr.update_host(domain, port) - url = '{}/setup-host'.format(net.compile_url(net.get_local_ip(), port)) + vmmgr.update_host(domain, port) + url = f'{net.compile_url(net.get_local_ip(), port)}/setup-host' response = self.render_json({'ok': request.session.lang.host_updated(url, url)}) - response.call_on_close(self.vmmgr.restart_nginx) + response.call_on_close(vmmgr.restart_nginx) return response def verify_dns_action(self, request): # Check if all FQDNs for all applications are resolvable and point to current external IP - domains = [self.vmmgr.domain]+['{}.{}'.format(self.conf['apps'][app]['host'], self.vmmgr.domain) for app in self.conf['apps']] + domain = config.get_host()[0] + domains = [domain]+[f'{definition["host"]}.{domain}' for app,definition in config.get_apps().items()] ipv4 = net.get_external_ip(4) ipv6 = net.get_external_ip(6) for domain in domains: @@ -304,8 +310,9 @@ class WSGIApp: def verify_http_action(self, request, **kwargs): # Check if all applications are accessible from the internet using 3rd party ping service proto = kwargs['proto'] - port = self.vmmgr.port if proto == 'https' else '80' - domains = [self.vmmgr.domain]+['{}.{}'.format(self.conf['apps'][app]['host'], self.vmmgr.domain) for app in self.conf['apps']] + domain, port = config.get_host() + port = port if proto == 'https' else '80' + domains = [domain]+[f'{definition["host"]}.{domain}' for app,definition in config.get_apps().items()] for domain in domains: url = net.compile_url(domain, port, proto) try: @@ -318,9 +325,9 @@ class WSGIApp: def update_cert_action(self, request): # Update certificate - either request via Let's Encrypt or manually upload files if request.form['method'] == 'selfsigned': - self.vmmgr.create_selfsigned_cert() + vmmgr.create_selfsigned_cert() elif request.form['method'] == 'automatic': - self.vmmgr.request_acme_cert() + vmmgr.request_acme_cert() elif request.form['method'] == 'manual': if not request.files['public']: return self.render_json({'error': request.session.lang.cert_file_missing()}) @@ -328,70 +335,71 @@ class WSGIApp: return self.render_json({'error': request.session.lang.key_file_missing()}) request.files['public'].save('/tmp/public.pem') request.files['private'].save('/tmp/private.pem') - self.vmmgr.install_manual_cert('/tmp/public.pem', '/tmp/private.pem') + vmmgr.install_manual_cert('/tmp/public.pem', '/tmp/private.pem') os.unlink('/tmp/public.pem') os.unlink('/tmp/private.pem') else: return self.render_json({'error': request.session.lang.cert_request_error()}) - url = net.compile_url(self.vmmgr.domain, self.vmmgr.port) + url = net.compile_url(*config.get_host()) return self.render_json({'ok': request.session.lang.cert_installed(url, url)}) def update_common_action(self, request): # Update common settings shared between apps - admin e-mail address, Google Maps API key email = request.form['email'] if not validator.is_valid_email(email): - request.session['msg'] = 'common:error:{}'.format(request.session.lang.invalid_email(email)) + request.session['msg'] = f'common:error:{request.session.lang.invalid_email(email)}' else: - self.vmmgr.update_common_settings(email, request.form['gmaps-api-key']) - request.session['msg'] = 'common:info:{}'.format(request.session.lang.common_updated()) + vmmgr.update_common_settings(email, request.form['gmaps-api-key']) + request.session['msg'] = f'common:info:{request.session.lang.common_updated()}' return redirect('/setup-apps') def update_repo_action(self, request): # Update repository URL and credentials url = request.form['repourl'] if not validator.is_valid_repo_url(url): - request.session['msg'] = 'repo:error:{}'.format(request.session.lang.invalid_url(url)) + request.session['msg'] = f'repo:error:{request.session.lang.invalid_url(url)}' else: - self.appmgr.update_repo_settings(url, request.form['repousername'], request.form['repopassword']) - request.session['msg'] = 'repo:info:{}'.format(request.session.lang.repo_updated()) + vmmgr.update_repo_settings(url, request.form['repousername'], request.form['repopassword']) + request.session['msg'] = f'repo:info:{request.session.lang.repo_updated()}' return redirect('/setup-apps') def update_app_visibility_action(self, request): # Update application visibility on portal page - self.appmgr.update_app_visibility(request.form['app'], request.form['value'] == 'true') + vmmgr.update_app_visibility(request.form['app'], request.form['value'] == 'true') return self.render_json({'ok': 'ok'}) def update_app_autostart_action(self, request): # Update value determining if the app should be automatically started after VM boot - self.appmgr.update_app_autostart(request.form['app'], request.form['value'] == 'true') + vmmgr.update_app_autostart(request.form['app'], request.form['value'] == 'true') return self.render_json({'ok': 'ok'}) def enqueue_app_action(self, request, action): # Common method for enqueuing app actions - self.queue.enqueue_action(request.form['app'], action) + app = request.form['app'] + self.queue.enqueue_action(app, action) response = self.render_json({'ok': self.render_setup_apps_table(request)}) response.call_on_close(self.queue.process_actions) return response def start_app_action(self, request): # Queues application start along with its dependencies - return self.enqueue_app_action(request, self.appmgr.start_app) + return self.enqueue_app_action(request, vmmgr.start_app) def stop_app_action(self, request): # Queues application stop along with its dependencies - return self.enqueue_app_action(request, self.appmgr.stop_app) + return self.enqueue_app_action(request, vmmgr.stop_app) def install_app_action(self, request): # Queues application installation - return self.enqueue_app_action(request, self.appmgr.install_app) + return self.enqueue_app_action(request, vmmgr.install_app) def uninstall_app_action(self, request): # Queues application uninstallation - return self.enqueue_app_action(request, self.appmgr.uninstall_app) + return self.enqueue_app_action(request, vmmgr.uninstall_app) def update_app_action(self, request): # Queues application update - return self.enqueue_app_action(request, self.appmgr.update_app) + return self.enqueue_app_action(request, vmmgr.update_app) def get_app_status_action(self, request): # Gets application and queue status @@ -410,7 +418,7 @@ class WSGIApp: if request.form['newpassword'] == '': return self.render_json({'error': request.session.lang.password_empty()}) # No need to explicitly validate old password, update_luks_password will raise exception if it's wrong - self.vmmgr.update_password(request.form['oldpassword'], request.form['newpassword']) + vmmgr.update_password(request.form['oldpassword'], request.form['newpassword']) except: return self.render_json({'error': request.session.lang.bad_password()}) return self.render_json({'ok': request.session.lang.password_changed()}) @@ -418,42 +426,34 @@ class WSGIApp: def reboot_vm_action(self, request): # Reboots VM response = self.render_json({'ok': request.session.lang.reboot_initiated()}) - response.call_on_close(self.vmmgr.reboot_vm) + response.call_on_close(vmmgr.reboot_vm) return response def shutdown_vm_action(self, request): # Shuts down VM response = self.render_json({'ok': request.session.lang.shutdown_initiated()}) - response.call_on_close(self.vmmgr.shutdown_vm) + response.call_on_close(vmmgr.shutdown_vm) return response - def reload_config_action(self, request): - # Reload configuration (called by vmmgr.register_app()) - self.conf.load() - return Response(status=204) - - def is_app_visible(self, app): - return app in self.conf['apps'] and self.conf['apps'][app]['visible'] and self.appmgr.is_service_started(app) - def update_ssh_keys_action(self, request): # Update authorized_keys file remote.set_authorized_keys(request.form['ssh-keys'].replace('\r', '')) - request.session['msg'] = 'ssh:info:{}'.format(request.session.lang.ssh_keys_installed()) + request.session['msg'] = f'ssh:info:{request.session.lang.ssh_keys_installed()}' return redirect('/setup-remote') def update_vpn_action(self, request): # Update WireGuard VPN listen port, virtual IP and peer list ip = request.form['vpn-lip'] if not ip.isdigit() or not 0 < int(ip) < 255: - request.session['msg'] = 'vpn:error:{}'.format(request.session.lang.invalid_ip()) + request.session['msg'] = f'vpn:error:{request.session.lang.invalid_ip()}' return redirect('/setup-remote') port = request.form['vpn-port'] if not port.isdigit() or not 0 < int(port) < 65536: - request.session['msg'] = 'vpn:error:{}'.format(request.session.lang.invalid_port()) + request.session['msg'] = f'vpn:error:{request.session.lang.invalid_port()}' return redirect('/setup-remote') peers = request.form['vpn-peers'].replace('\r', '') remote.set_wireguard_conf(ip, port, peers) - request.session['msg'] = 'vpn:info:{}'.format(request.session.lang.vpn_updated()) + request.session['msg'] = f'vpn:info:{request.session.lang.vpn_updated()}' return redirect('/setup-remote') def generate_vpn_key_action(self, request): diff --git a/usr/lib/python3.8/vmmgr/wsgilang.py b/usr/lib/python3.8/vmmgr/wsgilang.py index 75ac719..3577d45 100644 --- a/usr/lib/python3.8/vmmgr/wsgilang.py +++ b/usr/lib/python3.8/vmmgr/wsgilang.py @@ -40,11 +40,11 @@ class WSGILang: 'status_started': 'Spuštěna', 'status_stopping': 'Zastavuje se', 'status_stopped': 'Zastavena', - 'status_downloading': 'Stahuje se', - 'status_unpacking': 'Rozbaluje se', - 'status_installing': 'Instaluje se', - 'status_installing_deps': 'Instalují se závislosti', - 'status_uninstalling': 'Odinstalovává se', + 'status_downloading': 'Stahuje se {}', + 'status_unpacking': 'Rozbaluje se {}', + 'status_installing': 'Instaluje se {}', + 'status_updating': 'Aktualizuje se {}', + 'status_uninstalling': 'Odinstalovává se {}', 'status_not_installed': 'Není nainstalována', 'action_start': 'Spustit', 'action_stop': 'Zastavit', diff --git a/usr/share/vmmgr/templates/portal-admin.html b/usr/share/vmmgr/templates/portal-admin.html index ecf71cb..10c87aa 100644 --- a/usr/share/vmmgr/templates/portal-admin.html +++ b/usr/share/vmmgr/templates/portal-admin.html @@ -1,8 +1,8 @@ {% extends 'layout.html' %} {% block title %}Cluster NGO{% endblock %} {% block body %} -{% if is_app_visible('sahana') %} -{% set app = conf['apps']['sahana'] %} +{% if 'sahana' in visible_apps %} +{% set app = apps['sahana'] %}Registr kontaktů asociací, organizací, jednotek zaměstnanců, dobrovolníků, Registr prostředků, materiálních zdrojů určených pro činnost v krizových situacích, logistika krizového zboží ve skladištích, úkrytech, organizace lidských zdrojů, diobrovolníků, mapová vizualizace pro lokalizaci a popis krizové události a mnoho dalších funkcí.
@@ -13,8 +13,8 @@Přístup určený k bezpečnému vyzkoušení aplikace. Zde můžete přidávat i mazat testovací data.
@@ -25,8 +25,8 @@Samostatná instance Sahana EDEN s šablonou SAMBRO.
@@ -48,8 +48,8 @@Mapování krizové pomoci při odstraňování následků katastrof a koordinaci práce. Jde o majetek, ne o lidi.
@@ -60,8 +60,8 @@Repository management a datová analýza pro vytváření otevřených dat.
@@ -72,8 +72,8 @@Sběr dat s pomocí smartphone.
Aplikace pro návrh formulářů
@@ -83,8 +83,8 @@
Mobilní aplikace
@@ -108,8 +108,8 @@
Sběr dat s pomocí smartphone.
@@ -141,8 +141,8 @@
SMS messaging přes veřejné datové brány
@@ -160,8 +160,8 @@Dokument management na dokumentaci a projektovou dokumentaci
@@ -172,8 +172,8 @@Media management na foto a video z krizové události. Tvorba metadat, komentářů, lokalizace v čase a na mapě.
@@ -184,8 +184,8 @@Reakce na krizovou událost. Shromažďujte zprávy od obětí a pracovníků v terénu prostřednictvím SMS, e-mailu, webu, Twitteru.
@@ -214,8 +214,8 @@Usnadňuje tvorbu a řízení projektů s pomocí Kanban metodiky.
@@ -237,8 +237,8 @@Logistika hmotné pomoci pro humanitární potřeby.
@@ -249,8 +249,8 @@Zdravotní a nemocniční informační systém.
@@ -274,8 +274,8 @@Rozpočtování získávání finančních prostředků.
@@ -286,8 +286,8 @@Integrace zdravotnických a komunikačních služeb.
@@ -298,8 +298,8 @@Nástroj na rozvojovou, humanitární pomoc a mikrofinancování.
@@ -321,8 +321,8 @@Sada aplikací pro správu organizace.
@@ -334,7 +334,7 @@ {% endif %} {% if false %} -{% set app = conf['apps']['diaspora'] %} +{% set app = apps['diaspora'] %}Autonomní sociání síť s možností propojení do cizích sociálních sítí.
diff --git a/usr/share/vmmgr/templates/portal-user.html b/usr/share/vmmgr/templates/portal-user.html index d68fd60..cfd5837 100644 --- a/usr/share/vmmgr/templates/portal-user.html +++ b/usr/share/vmmgr/templates/portal-user.html @@ -1,119 +1,119 @@ {% extends 'layout.html' %} {% block title %}Cluster NGO{% endblock %} {% block body %} -{% if is_app_visible('sahana-demo') %} +{% if 'sahana-demo' in visible_apps %}Přístup určený k bezpečnému vyzkoušení aplikace. Zde můžete přidávat i mazat testovací data.
Samostatná instance s šablonou pro centrum hlášení a výstrah.
Mapování krizové pomoci při odstraňování následků katastrof a koordinaci práce. Jde o majetek, ne o lidi.
Repository management a datová analýza pro vytváření otevřených dat.
Sběr dat s pomocí smartphone.
Aplikace pro návrh formulářů
Sběr dat s pomocí smartphone.
Sběr dat s pomocí smartphone.
SMS messaging přes veřejné datové brány
Dokument management na dokumentaci a projektovou dokumentaci
Media management na foto a video z krizové události. Tvorba metadat, komentářů, lokalizace v čase a na mapě.
Reakce na krizovou událost. Shromažďujte zprávy od obětí a pracovníků v terénu prostřednictvím SMS, e-mailu, webu, Twitteru.
Usnadňuje tvorbu a řízení projektů s pomocí Kanban metodiky.
Zdravotní a nemocniční informační systém.
Rozpočtování získávání finančních prostředků.
Integrace zdravotnických a komunikačních služeb.
Nástroj na rozvojovou, humanitární pomoc a mikrofinancování.
Sada aplikací pro správu organizace.
diff --git a/usr/share/vmmgr/templates/setup-apps.html b/usr/share/vmmgr/templates/setup-apps.html index 486bd34..88fc96b 100644 --- a/usr/share/vmmgr/templates/setup-apps.html +++ b/usr/share/vmmgr/templates/setup-apps.html @@ -26,11 +26,11 @@URL serveru: | -+ | |||||||||||||||
Uživatelské jméno: | -+ | |||||||||||||||
Heslo: | @@ -56,12 +56,12 @@
+ | Administrativní e-mail na který budou doručovány zprávy a upozornění z aplikací. Stejná e-mailová adresa bude také využita některými aplikacemi pro odesílání zpráv uživatelům. | ||||||
Google Maps API klíč | -+ | API klíč pro službu Google Maps, která je využita některými aplikacemi. | |||||
Doména | -+ | Plně kvalifikovaný doménový název, na kterém bude dostupný aplikační portál. Jednotlivé aplikace budou dostupné na subdoménách této domény. | |
Port | -+ | HTTPS port na kterém budou dostupné aplikace. Porty 22, 25, 80 a 8080 jsou vyhrazeny k jiným účelům. Výchozí HTTPS port je 443. | |