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'] %}

Sahana EDENSahana EDEN

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 @@
{% endif %} -{% if is_app_visible('sahana-demo') %} -{% set app = conf['apps']['sahana-demo'] %} +{% if 'sahana-demo' in visible_apps %} +{% set app = apps['sahana-demo'] %}

Sahana EDEN DEMOSahana EDEN DEMO

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 @@
{% endif %} -{% if is_app_visible('sambro') %} -{% set app = conf['apps']['sambro'] %} +{% if 'sambro' in visible_apps %} +{% set app = apps['sambro'] %}

Sahana EDEN SAMBROSahana EDEN SAMBRO

Samostatná instance Sahana EDEN s šablonou SAMBRO.

@@ -48,8 +48,8 @@
{% endif %} -{% if is_app_visible('crisiscleanup') %} -{% set app = conf['apps']['crisiscleanup'] %} +{% if 'crisiscleanup' in visible_apps %} +{% set app = apps['crisiscleanup'] %}

Crisis CleanupCrisis Cleanup

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 @@
{% endif %} -{% if is_app_visible('ckan') %} -{% set app = conf['apps']['ckan'] %} +{% if 'ckan' in visible_apps %} +{% set app = apps['ckan'] %}

CKANCKAN

Repository management a datová analýza pro vytváření otevřených dat.

@@ -72,8 +72,8 @@
{% endif %} -{% if is_app_visible('opendatakit-build') %} -{% set app = conf['apps']['opendatakit-build'] %} +{% if 'opendatakit-build' in visible_apps %} +{% set app = apps['opendatakit-build'] %}

Open Data KitODK Build

Sběr dat s pomocí smartphone.
Aplikace pro návrh formulářů
@@ -83,8 +83,8 @@

{% endif %} -{% if is_app_visible('opendatakit') %} -{% set app = conf['apps']['opendatakit'] %} +{% if 'opendatakit' in visible_apps %} +{% set app = apps['opendatakit'] %}

Open Data KitODK Collect

Mobilní aplikace
@@ -108,8 +108,8 @@

{% endif %} -{% if is_app_visible('openmapkit') %} -{% set app = conf['apps']['openmapkit'] %} +{% if 'openmapkit' in visible_apps %} +{% set app = apps['openmapkit'] %}

Open Map KitOpenMapKit Server

Sběr dat s pomocí smartphone.
@@ -141,8 +141,8 @@

{% endif %} -{% if is_app_visible('frontlinesms') %} -{% set app = conf['apps']['frontlinesms'] %} +{% if 'frontlinesms' in visible_apps %} +{% set app = apps['frontlinesms'] %}

FrontlineSMSFrontlineSMS

SMS messaging přes veřejné datové brány

@@ -160,8 +160,8 @@
{% endif %} -{% if is_app_visible('seeddms') %} -{% set app = conf['apps']['seeddms'] %} +{% if 'seeddms' in visible_apps %} +{% set app = apps['seeddms'] %}

SeedDMSSeedDMS

Dokument management na dokumentaci a projektovou dokumentaci

@@ -172,8 +172,8 @@
{% endif %} -{% if is_app_visible('pandora') %} -{% set app = conf['apps']['pandora'] %} +{% if 'pandora' in visible_apps %} +{% set app = apps['pandora'] %}

Pan.do/raPan.do/ra

Media management na foto a video z krizové události. Tvorba metadat, komentářů, lokalizace v čase a na mapě.

@@ -184,8 +184,8 @@
{% endif %} -{% if is_app_visible('ushahidi') %} -{% set app = conf['apps']['ushahidi'] %} +{% if 'ushahidi' in visible_apps %} +{% set app = apps['ushahidi'] %}

UshahidiUshahidi

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 @@
{% endif %} -{% if is_app_visible('kanboard') %} -{% set app = conf['apps']['kanboard'] %} +{% if 'kanboard' in visible_apps %} +{% set app = apps['kanboard'] %}

KanboardKanboard

Usnadňuje tvorbu a řízení projektů s pomocí Kanban metodiky.

@@ -237,8 +237,8 @@
{% endif %} -{% if is_app_visible('cts') %} -{% set app = conf['apps']['cts'] %} +{% if 'cts' in visible_apps %} +{% set app = apps['cts'] %}

CTSCTS

Logistika hmotné pomoci pro humanitární potřeby.

@@ -249,8 +249,8 @@
{% endif %} -{% if is_app_visible('gnuhealth') %} -{% set app = conf['apps']['gnuhealth'] %} +{% if 'gnuhealth' in visible_apps %} +{% set app = apps['gnuhealth'] %}

GNU HealthGNU Health

Zdravotní a nemocniční informační systém.

@@ -274,8 +274,8 @@
{% endif %} -{% if is_app_visible('sigmah') %} -{% set app = conf['apps']['sigmah'] %} +{% if 'sigmah' in visible_apps %} +{% set app = apps['sigmah'] %}

SigmahSigmah

Rozpočtování získávání finančních prostředků.

@@ -286,8 +286,8 @@
{% endif %} -{% if is_app_visible('motech') %} -{% set app = conf['apps']['motech'] %} +{% if 'motech' in visible_apps %} +{% set app = apps['motech'] %}

MotechMotech

Integrace zdravotnických a komunikačních služeb.

@@ -298,8 +298,8 @@
{% endif %} -{% if is_app_visible('mifosx') %} -{% set app = conf['apps']['mifosx'] %} +{% if 'mifosx' in visible_apps %} +{% set app = apps['mifosx'] %}

Mifos XMifos X

Nástroj na rozvojovou, humanitární pomoc a mikrofinancování.

@@ -321,8 +321,8 @@
{% endif %} -{% if is_app_visible('odoo') %} -{% set app = conf['apps']['odoo'] %} +{% if 'odoo' in visible_apps %} +{% set app = apps['odoo'] %}

OdooOdoo

Sada aplikací pro správu organizace.

@@ -334,7 +334,7 @@ {% endif %} {% if false %} -{% set app = conf['apps']['diaspora'] %} +{% set app = apps['diaspora'] %}

diaspora*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 %}

Řízení humanítární činnosti

Přístup určený k bezpečnému vyzkoušení aplikace. Zde můžete přidávat i mazat testovací data.

{% endif %} -{% if is_app_visible('sambro') %} +{% if 'sambro' in visible_apps %}

Centrum hlášení a výstrah

Samostatná instance s šablonou pro centrum hlášení a výstrah.

{% endif %} -{% if is_app_visible('crisiscleanup') %} +{% if 'crisiscleanup' in visible_apps %}

Mapování následků katastrof

Mapování krizové pomoci při odstraňování následků katastrof a koordinaci práce. Jde o majetek, ne o lidi.

{% endif %} -{% if is_app_visible('ckan') %} +{% if 'ckan' in visible_apps %}

Datový sklad

Repository management a datová analýza pro vytváření otevřených dat.

{% endif %} -{% if is_app_visible('opendatakit-build') %} +{% if 'opendatakit-build' in visible_apps %}

Sběr formulářových dat

Sběr dat s pomocí smartphone.
Aplikace pro návrh formulářů

{% endif %} -{% if is_app_visible('opendatakit') %} +{% if 'opendatakit' in visible_apps %}

Sběr formulářových dat

Sběr dat s pomocí smartphone.

{% endif %} -{% if is_app_visible('openmapkit') %} +{% if 'openmapkit' in visible_apps %}

Sběr mapových dat

Sběr dat s pomocí smartphone.

{% endif %} -{% if is_app_visible('frontlinesms') %} +{% if 'frontlinesms' in visible_apps %}

Hromadné odesílání zpráv

SMS messaging přes veřejné datové brány

{% endif %} -{% if is_app_visible('seeddms') %} +{% if 'seeddms' in visible_apps %}

Archiv dokumentace

Dokument management na dokumentaci a projektovou dokumentaci

{% endif %} -{% if is_app_visible('pandora') %} +{% if 'pandora' in visible_apps %}

Archiv medií

Media management na foto a video z krizové události. Tvorba metadat, komentářů, lokalizace v čase a na mapě.

{% endif %} -{% if is_app_visible('ushahidi') %} +{% if 'ushahidi' in visible_apps %}

Skupinová reakce na události

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.

{% endif %} -{% if is_app_visible('kanboard') %} +{% if 'kanboard' in visible_apps %}

Kanban řízení projektů

Usnadňuje tvorbu a řízení projektů s pomocí Kanban metodiky.

{% endif %} -{% if is_app_visible('gnuhealth') %} +{% if 'gnuhealth' in visible_apps %}

Lékařské záznamy pacientů

Zdravotní a nemocniční informační systém.

{% endif %} -{% if is_app_visible('sigmah') %} +{% if 'sigmah' in visible_apps %}

Finanční řízení sbírek

Rozpočtování získávání finančních prostředků.

{% endif %} -{% if is_app_visible('motech') %} +{% if 'motech' in visible_apps %}

Automatizace komunikace

Integrace zdravotnických a komunikačních služeb.

{% endif %} -{% if is_app_visible('mifosx') %} +{% if 'mifosx' in visible_apps %}

Mikrofinancování rozvojových projektů

Nástroj na rozvojovou, humanitární pomoc a mikrofinancování.

{% endif %} -{% if is_app_visible('odoo') %} +{% if 'odoo' in visible_apps %}

Správa organizace

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 @@ - + - + @@ -56,12 +56,12 @@
URL serveru:
Uživatelské jméno:
Heslo:
- + - + diff --git a/usr/share/vmmgr/templates/setup-host.html b/usr/share/vmmgr/templates/setup-host.html index 0e55066..d486d3d 100644 --- a/usr/share/vmmgr/templates/setup-host.html +++ b/usr/share/vmmgr/templates/setup-host.html @@ -8,12 +8,12 @@
E-mail 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.
- + - + @@ -32,14 +32,14 @@

Na jmenném serveru domény nastavené v sekci HTTPS Hostitel nastavte DNS záznamy typu A, případně i AAAA pro následující doménové názvy a nasměrujte je na vnější (tj. dostupnou z internetu) IP adresu tohoto virtuální stroje. Toto nastavení lze obvykle provést skrze webové rozhraní registrátora domény.

Vnější IPv4 {% if ex_ipv4 %}je {{ ex_ipv4 }}{% else %}nebyla zjištěna{% endif %} a IPv6 {% if ex_ipv6 %}je {{ ex_ipv6 }}{% else %}nebyla zjištěna{% endif %}.

    -
  • {{ conf['host']['domain'] }}
  • -
  • *.{{ conf['host']['domain'] }}
  • +
  • {{ domain }}
  • +
  • *.{{ domain }}

Pokud jmenný server nepodporuje wildcard záznamy nebo pokud nemůžete či nechcete dedikovat virtuálnímu stroji všechny subdomény, nastavte místo toho záznamy pro následující doménové názvy

    -
  • {{ conf['host']['domain'] }}
  • - {% for app in conf['apps']|sort %} -
  • {{ conf['apps'][app]['host'] }}.{{ conf['host']['domain'] }}
  • +
  • {{ domain }}
  • + {% for app in apps|sort %} +
  • {{ apps[app]['host'] }}.{{ domain }}
  • {% endfor %}
@@ -52,14 +52,14 @@

Firewall a NAT

-

Pokud je stávající připojení k internetu zprostředkováno routerem s NAT, na hypervizoru je nastaven firewall nebo existují jiné restrikce síťového provozu, je nutno upravit nastavení příslušných komponent, aby byl provoz na portu {{ conf['host']['port'] }} (nastaveném v sekci HTTPS Hostitel) z internetu korektně nasměrován na místní adresu virtuálního stroje.

+

Pokud je stávající připojení k internetu zprostředkováno routerem s NAT, na hypervizoru je nastaven firewall nebo existují jiné restrikce síťového provozu, je nutno upravit nastavení příslušných komponent, aby byl provoz na portu {{ port }} (nastaveném v sekci HTTPS Hostitel) z internetu korektně nasměrován na místní adresu virtuálního stroje.

Pokud bude využit systém automatického vyžádání a obnovy certifikátu (sekce HTTPS certifikát), je nutno aby byl na místní adresu virtuálního stroje nasměrován i port 80, případně byla nastavena HTTP proxy přesměrovávající doménová jména zmíněná v sekci DNS záznamy.

Místní IPv4 {% if in_ipv4 %}je {{ in_ipv4 }}{% else %}nebyla zjištěna{% endif %} a IPv6 {% if in_ipv6 %}je {{ in_ipv6 }}{% else %}nebyla zjištěna{% endif %}.

- +
- Ověřuje se nastavení firewallu a NAT pro port {{ conf['host']['port'] }}, prosím čekejte... + Ověřuje se nastavení firewallu a NAT pro port {{ port }}, prosím čekejte...
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.