From c2b383e5c84f68a93dceb9de552648fa57b4e409 Mon Sep 17 00:00:00 2001 From: Disassembler Date: Mon, 5 Nov 2018 14:41:10 +0100 Subject: [PATCH] Move tools into best fitting modules --- usr/bin/vmmgr | 4 +- usr/lib/python3.6/vmmgr/__init__.py | 284 +-------------------------- usr/lib/python3.6/vmmgr/appmgr.py | 74 +++++-- usr/lib/python3.6/vmmgr/templates.py | 127 ++++++++++++ usr/lib/python3.6/vmmgr/tools.py | 98 +-------- usr/lib/python3.6/vmmgr/vmmgr.py | 218 ++++++++++++++++++++ usr/lib/python3.6/vmmgr/wsgiapp.py | 26 +-- usr/lib/python3.6/vmmgr/wsgilang.py | 3 +- usr/share/vmmgr/wsgi.py | 3 +- 9 files changed, 428 insertions(+), 409 deletions(-) create mode 100644 usr/lib/python3.6/vmmgr/templates.py create mode 100644 usr/lib/python3.6/vmmgr/vmmgr.py diff --git a/usr/bin/vmmgr b/usr/bin/vmmgr index c5340a3..b1bb693 100755 --- a/usr/bin/vmmgr +++ b/usr/bin/vmmgr @@ -2,9 +2,7 @@ # -*- coding: utf-8 -*- import argparse -import sys -from vmmgr import VMMgr -from vmmgr.config import Config +from vmmgr import Config, VMMgr parser = argparse.ArgumentParser(description='VM application manager') subparsers = parser.add_subparsers() diff --git a/usr/lib/python3.6/vmmgr/__init__.py b/usr/lib/python3.6/vmmgr/__init__.py index 54a7dbd..b688139 100644 --- a/usr/lib/python3.6/vmmgr/__init__.py +++ b/usr/lib/python3.6/vmmgr/__init__.py @@ -1,279 +1,13 @@ # -*- coding: utf-8 -*- -import os -import shutil -import subprocess - -from . import tools +from .appmgr import AppMgr from .config import Config +from .vmmgr import VMMgr +from .wsgiapp import WSGIApp -VERSION = '0.0.1' - -ISSUE_FILE = '/etc/issue' -NGINX_DIR = '/etc/nginx/conf.d' -ACME_CRON = '/etc/periodic/daily/acme-sh' -CERT_PUB_FILE = '/etc/ssl/services.pem' -CERT_KEY_FILE = '/etc/ssl/services.key' -CERT_SAN_FILE = '/etc/ssl/san.cnf' - -NGINX_TEMPLATE = '''server {{ - listen [::]:{port} ssl http2; - server_name {host}.{domain}; - - access_log /var/log/nginx/{app}.access.log; - error_log /var/log/nginx/{app}.error.log; - - location / {{ - proxy_pass http://{app}:8080; - }} - - error_page 502 /502.html; - location = /502.html {{ - root /usr/share/vmmgr/templates; - }} - - location = /vm-ping {{ - add_header Content-Type text/plain; - return 200 "vm-pong"; - }} -}} -''' - -NGINX_DEFAULT_TEMPLATE = '''server {{ - listen [::]:80 default_server ipv6only=off; - - location / {{ - return 301 https://$host:{port}$request_uri; - }} - - location /.well-known/acme-challenge/ {{ - root /etc/acme.sh.d; - }} - - location = /vm-ping {{ - add_header Content-Type text/plain; - return 200 "vm-pong"; - }} -}} - -server {{ - listen [::]:{port} ssl http2 default_server ipv6only=off; - - location / {{ - proxy_pass http://127.0.0.1:8080; - }} - - location /static {{ - root /usr/share/vmmgr; - }} - - error_page 502 /502.html; - location = /502.html {{ - root /usr/share/vmmgr/templates; - }} - - location = /vm-ping {{ - add_header Content-Type text/plain; - return 200 "vm-pong"; - }} -}} - -server {{ - listen [::]:{port} ssl http2; - server_name ~^(.*)\.{domain_esc}$; - - location / {{ - return 503; - }} - - location /static {{ - root /usr/share/vmmgr; - }} - - error_page 503 /503.html; - location = /503.html {{ - root /usr/share/vmmgr/templates; - }} - - location = /vm-ping {{ - add_header Content-Type text/plain; - return 200 "vm-pong"; - }} -}} -''' - -ISSUE_TEMPLATE = ''' -\x1b[1;32m _____ _ _ __ ____ __ - / ____| | | | | \\\\ \\\\ / / \\\\/ | - | (___ _ __ ___ | |_| |_ ___ _ _\\\\ \\\\ / /| \\\\ / | - \\\\___ \\\\| '_ \\\\ / _ \\\\| __| __/ _ \\\\ '__\\\\ \\\\/ / | |\\\\/| | - ____) | |_) | (_) | |_| || __/ | \\\\ / | | | | - |_____/| .__/ \\\\___/ \\\\__|\\\\__\\\\___|_| \\\\/ |_| |_| - | | - |_|\x1b[0m - - \x1b[1;33mUPOZORNĚNÍ:\x1b[0m Neoprávněný přístup k tomuto zařízení je zakázán. - Musíte mít výslovné oprávnění k přístupu nebo konfiguraci tohoto zařízení. - Neoprávněné pokusy a kroky k přístupu nebo používání tohoto systému mohou mít - za následek občanské nebo trestní sankce. - - \x1b[1;33mCAUTION:\x1b[0m Unauthozired access to this device is prohibited. - You must have explicit, authorized permission to access or configure this - device. Unauthorized attempts and actions to access or use this system may - result in civil or criminal penalties. - - Pro přístup k aplikacím otevřete jednu z těcho URL v internetovém prohlížeči. - Open one the following URLs in web browser to access the applications. - - - \x1b[1m{url}\x1b[0m - - \x1b[1m{ip}\x1b[0m\x1b[?1c -''' - -ACME_CRON_TEMPLATE = '''#!/bin/sh - -[ -x /usr/bin/acme.sh ] && /usr/bin/acme.sh --cron >/dev/null -''' - -CERT_SAN = '''[ req ] -distinguished_name = dn -x509_extensions = ext -[ dn ] -[ ext ] -subjectAltName=DNS:{domain},DNS:*.{domain}" -''' - -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(self, app, login, password): - # Write a file with credentials of a newly installed application which - # will be picked up by thread performing the installation after the install script finishes - login = login if login else 'N/A' - password = password if password else 'N/A' - with open('/tmp/{}.credentials'.format(app), 'w') as f: - f.write('{}\n{}'.format(login, password)) - - def prepare_container(self): - # Extract the variables from values given via lxc.hook.pre-start hook - app = os.environ['LXC_NAME'] - # Remove ephemeral layer data - tools.clean_ephemeral_layer(app) - # Configure host and common params used in the app - self.configure_app(app) - - def register_container(self): - # Extract the variables from values given via lxc.hook.start-host hook - app = os.environ['LXC_NAME'] - pid = os.environ['LXC_PID'] - # Lease the first unused IP to the container - ip = tools.update_hosts_lease(app, True) - tools.set_container_ip(pid, ip) - - def unregister_container(self): - # Extract the variables from values given via lxc.hook.post-stop hook - app = os.environ['LXC_NAME'] - # Release the container IP - tools.update_hosts_lease(app, False) - # Remove ephemeral layer data - tools.clean_ephemeral_layer(app) - - def configure_app(self, app): - script = os.path.join('/srv', app, 'update-conf.sh') - if os.path.exists(script): - setup_env = os.environ.copy() - setup_env['DOMAIN'] = self.domain - setup_env['PORT'] = self.port - setup_env['EMAIL'] = self.conf['common']['email'] - setup_env['GMAPS_API_KEY'] = self.conf['common']['gmaps-api-key'] - subprocess.run([script], env=setup_env, check=True) - - 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(NGINX_TEMPLATE.format(app=app, host=self.conf['packages'][app]['host'], domain=self.domain, port=self.port)) - tools.reload_nginx() - - def unregister_proxy(self, app): - # Remove proxy configuration and reload nginx - os.unlink(os.path.join(NGINX_DIR, '{}.conf'.format(app))) - tools.reload_nginx() - - def update_host(self, domain, port): - # Update domain and port and rebuild all configuration. Web interface calls tools.restart_nginx() in WSGI close handler - self.domain = self.conf['host']['domain'] = domain - self.port = self.conf['host']['port'] = port - self.conf.save() - # Rebuild and restart nginx if it was requested. - self.rebuild_nginx() - - def rebuild_nginx(self): - # Rebuild nginx config for the portal app. Web interface calls tools.restart_nginx() in WSGI close handler - with open(os.path.join(NGINX_DIR, 'default.conf'), 'w') as f: - f.write(NGINX_DEFAULT_TEMPLATE.format(port=self.port, domain_esc=self.domain.replace('.', '\.'))) - - def rebuild_issue(self): - # Compile the URLs displayed in terminal banner and rebuild the file - with open(ISSUE_FILE, 'w') as f: - f.write(ISSUE_TEMPLATE.format(url=tools.compile_url(self.domain, self.port), ip=tools.compile_url(tools.get_local_ip(), self.port))) - - def update_password(self, oldpassword, newpassword): - # Update LUKS password and adminpwd for WSGI application - input = '{}\n{}'.format(oldpassword, newpassword).encode() - subprocess.run(['cryptsetup', 'luksChangeKey', '/dev/sda2'], input=input, check=True) - # Update bcrypt-hashed password in config - self.conf['host']['adminpwd'] = tools.adminpwd_hash(newpassword) - # Save config to file - self.conf.save() - - def create_selfsigned_cert(self): - # Remove acme.sh cronjob - if os.path.exists(ACME_CRON): - os.unlink(ACME_CRON) - # Create selfsigned certificate with wildcard alternative subject name - with open(os.path.join(CERT_SAN_FILE), 'w') as f: - f.write(CERT_SAN.format(domain=self.domain)) - subprocess.run(['openssl', 'req', '-config', CERT_SAN_FILE, '-x509', '-new', '-out', CERT_PUB_FILE, '-keyout', CERT_KEY_FILE, '-nodes', '-days', '7305', '-subj', '/CN={}'.format(self.domain)], check=True) - os.chmod(CERT_KEY_FILE, 0o640) - - def request_acme_cert(self): - # Remove all possible conflicting certificates requested in the past - certs = [i for i in os.listdir('/etc/acme.sh.d') if i not in ('account.conf', 'ca', 'http.header')] - for cert in certs: - if cert != self.domain: - subprocess.run(['/usr/bin/acme.sh', '--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('/etc/acme.sh.d', self.domain)): - cmd = ['/usr/bin/acme.sh', '--issue', '-d', self.domain] - for app in self.conf['apps'].copy(): - cmd += ['-d', '{}.{}'.format(self.conf['packages'][app]['host'], self.domain)] - cmd += ['-w', '/etc/acme.sh.d'] - # 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', '--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', '--install-cert', '-d', self.domain, '--key-file', CERT_KEY_FILE, '--fullchain-file', CERT_PUB_FILE, '--reloadcmd', '/sbin/service nginx reload'], check=True) - # Install acme.sh cronjob - with open(ACME_CRON, 'w') as f: - f.write(ACME_CRON_TEMPLATE) - - def install_manual_cert(self, public_file, private_file): - # Remove acme.sh cronjob - if os.path.exists(ACME_CRON): - os.unlink(ACME_CRON) - # Copy certificate files - shutil.copyfile(public_file, CERT_PUB_FILE) - shutil.copyfile(private_file, CERT_KEY_FILE) - os.chmod(CERT_KEY_FILE, 0o640) - # Reload nginx - tools.reload_nginx() +__all__ = [ + 'AppMgr', + 'Config', + 'VMMgr', + 'WSGIApp' +] diff --git a/usr/lib/python3.6/vmmgr/appmgr.py b/usr/lib/python3.6/vmmgr/appmgr.py index 3d304a7..c1f63f6 100644 --- a/usr/lib/python3.6/vmmgr/appmgr.py +++ b/usr/lib/python3.6/vmmgr/appmgr.py @@ -40,7 +40,10 @@ class AppMgr: def fetch_online_packages(self): # Fetches and verifies online packages. Can raise InvalidSignature online_packages = {} - packages = self.get_repo_resource('packages').content + packages = self.get_repo_resource('packages') + if packages.status_code != 200: + return packages.status_code + packages = packages.content packages_sig = self.get_repo_resource('packages.sig').content with open(PUB_FILE, 'rb') as f: pub_key = load_pem_public_key(f.read(), default_backend()) @@ -48,23 +51,30 @@ class AppMgr: online_packages = json.loads(packages) # Minimze the time when self.online_packages is out of sync self.online_packages = online_packages + return 200 def start_app(self, item): # Start the actual app service app = item.key - if app in self.conf['apps'] and not tools.is_service_started(app): - tools.start_service(app) + 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 tools.is_service_started(app): - tools.stop_service(app) + 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 tools.get_service_deps(app): - if not any([tools.is_service_started(d) for d in deps[dep]]): - tools.stop_service(dep) + 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 @@ -77,6 +87,14 @@ class AppMgr: 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 app = item.key @@ -102,7 +120,7 @@ class AppMgr: # Main uninstallation function. Wrapper for uninstall script, filesystem purge and unregistration app = item.key self.stop_app(item) - if tools.is_service_autostarted(app): + if self.is_service_autostarted(app): self.update_app_autostart(app, False) deps = self.get_install_deps(app, False)[::-1] for dep in deps: @@ -119,12 +137,23 @@ class AppMgr: if chunk: installitem.downloaded += f.write(chunk) # Verify hash - if self.online_packages[name]['sha512'] != hash_file(tmp_archive): + if self.online_packages[name]['sha512'] != self.hash_file(tmp_archive): raise InvalidSignature(name) + def hash_file(self, file_path): + sha512 = hashlib.sha512() + with open(file_path, 'rb') as f: + while True: + data = f.read(65536) + if not data: + break + sha512.update(data) + return sha512.hexdigest() + def unpack_package(self, name): # Unpack archive tmp_archive = '/tmp/{}.tar.xz'.format(name) + print('Unpacking', ['tar', 'xJf', tmp_archive]) subprocess.run(['tar', 'xJf', tmp_archive], cwd='/', check=True) os.unlink(tmp_archive) @@ -218,7 +247,7 @@ class AppMgr: # Fisrt, build a dictionary of {app: [needs]} needs = {} for app in self.conf['apps'].copy(): - needs[app] = tools.get_service_deps(app) + needs[app] = self.get_service_deps(app) # Then reverse it to {need: [apps]} deps = {} for app, need in needs.items(): @@ -226,6 +255,15 @@ class AppMgr: deps.setdefault(n, []).append(app) return deps + def get_service_deps(self, app): + # Get "need" line from init script and split it to 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_common_settings(self, email, gmaps_api_key): # Update common configuration values self.conf['common']['email'] = email @@ -239,12 +277,8 @@ class AppMgr: self.conf['repo']['pwd'] = pwd self.conf.save() -def hash_file(file_path): - sha512 = hashlib.sha512() - with open(file_path, 'rb') as f: - while True: - data = f.read(65536) - if not data: - break - sha512.update(data) - return sha512.hexdigest() + def shutdown_vm(self): + subprocess.run(['/sbin/poweroff']) + + def reboot_vm(self): + subprocess.run(['/sbin/reboot']) diff --git a/usr/lib/python3.6/vmmgr/templates.py b/usr/lib/python3.6/vmmgr/templates.py new file mode 100644 index 0000000..570d687 --- /dev/null +++ b/usr/lib/python3.6/vmmgr/templates.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- + +NGINX = '''server {{ + listen [::]:{port} ssl http2; + server_name {host}.{domain}; + + access_log /var/log/nginx/{app}.access.log; + error_log /var/log/nginx/{app}.error.log; + + location / {{ + proxy_pass http://{app}:8080; + }} + + error_page 502 /502.html; + location = /502.html {{ + root /usr/share/vmmgr/templates; + }} + + location = /vm-ping {{ + add_header Content-Type text/plain; + return 200 "vm-pong"; + }} +}} +''' + +NGINX_DEFAULT = '''server {{ + listen [::]:80 default_server ipv6only=off; + + location / {{ + return 301 https://$host:{port}$request_uri; + }} + + location /.well-known/acme-challenge/ {{ + root /etc/acme.sh.d; + }} + + location = /vm-ping {{ + add_header Content-Type text/plain; + return 200 "vm-pong"; + }} +}} + +server {{ + listen [::]:{port} ssl http2 default_server ipv6only=off; + + location / {{ + proxy_pass http://127.0.0.1:8080; + }} + + location /static {{ + root /usr/share/vmmgr; + }} + + error_page 502 /502.html; + location = /502.html {{ + root /usr/share/vmmgr/templates; + }} + + location = /vm-ping {{ + add_header Content-Type text/plain; + return 200 "vm-pong"; + }} +}} + +server {{ + listen [::]:{port} ssl http2; + server_name ~^(.*)\.{domain_esc}$; + + location / {{ + return 503; + }} + + location /static {{ + root /usr/share/vmmgr; + }} + + error_page 503 /503.html; + location = /503.html {{ + root /usr/share/vmmgr/templates; + }} + + location = /vm-ping {{ + add_header Content-Type text/plain; + return 200 "vm-pong"; + }} +}} +''' + +ISSUE = ''' +\x1b[1;32m _____ _ _ __ ____ __ + / ____| | | | | \\\\ \\\\ / / \\\\/ | + | (___ _ __ ___ | |_| |_ ___ _ _\\\\ \\\\ / /| \\\\ / | + \\\\___ \\\\| '_ \\\\ / _ \\\\| __| __/ _ \\\\ '__\\\\ \\\\/ / | |\\\\/| | + ____) | |_) | (_) | |_| || __/ | \\\\ / | | | | + |_____/| .__/ \\\\___/ \\\\__|\\\\__\\\\___|_| \\\\/ |_| |_| + | | + |_|\x1b[0m + + \x1b[1;33mUPOZORNĚNÍ:\x1b[0m Neoprávněný přístup k tomuto zařízení je zakázán. + Musíte mít výslovné oprávnění k přístupu nebo konfiguraci tohoto zařízení. + Neoprávněné pokusy a kroky k přístupu nebo používání tohoto systému mohou mít + za následek občanské nebo trestní sankce. + + \x1b[1;33mCAUTION:\x1b[0m Unauthozired access to this device is prohibited. + You must have explicit, authorized permission to access or configure this + device. Unauthorized attempts and actions to access or use this system may + result in civil or criminal penalties. + + Pro přístup k aplikacím otevřete jednu z těcho URL v internetovém prohlížeči. + Open one the following URLs in web browser to access the applications. + + - \x1b[1m{url}\x1b[0m + - \x1b[1m{ip}\x1b[0m\x1b[?1c +''' + +ACME_CRON = '''#!/bin/sh + +[ -x /usr/bin/acme.sh ] && /usr/bin/acme.sh --cron >/dev/null +''' + +CERT_SAN = '''[ req ] +distinguished_name = dn +x509_extensions = ext +[ dn ] +[ ext ] +subjectAltName=DNS:{domain},DNS:*.{domain}" +''' diff --git a/usr/lib/python3.6/vmmgr/tools.py b/usr/lib/python3.6/vmmgr/tools.py index dad8285..86887eb 100644 --- a/usr/lib/python3.6/vmmgr/tools.py +++ b/usr/lib/python3.6/vmmgr/tools.py @@ -3,16 +3,12 @@ import bcrypt import dns.exception import dns.resolver -import fcntl import os import requests -import shutil import socket import subprocess -from cryptography import x509 -from cryptography.hazmat.backends import default_backend -from cryptography.x509.oid import NameOID +# Network tools def compile_url(domain, port, proto='https'): port = '' if (proto == 'https' and port == '443') or (proto == 'http' and port == '80') else ':{}'.format(port) @@ -64,100 +60,10 @@ def ping_url(url): except: return False -def get_service_deps(app): - # Get "needs" line from init script and split it to list - try: - with open(os.path.join('/etc/init.d', app), 'r') as f: - for line in f.readlines(): - if line.strip().startswith('need'): - return line.split()[1:] - except: - pass - return [] - -def is_service_started(app): - # Check OpenRC service status without calling any binary - return os.path.exists(os.path.join('/run/openrc/started', app)) - -def is_service_autostarted(app): - # Check OpenRC service enablement - return os.path.exists(os.path.join('/etc/runlevels/default', app)) - -def start_service(service): - subprocess.run(['/sbin/service', service, 'start'], check=True) - -def stop_service(service): - subprocess.run(['/sbin/service', service, 'stop'], check=True) - -def reload_nginx(): - subprocess.run(['/usr/sbin/nginx', '-s', 'reload']) - -def restart_nginx(): - restart_service(['/sbin/service', 'nginx', 'restart']) - -def get_cert_info(cert): - # Gather certificate data important for setup-host - with open(cert, 'rb') as f: - 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), - 'method': 'manual'} - if os.path.exists('/etc/periodic/daily/acme-sh'): - data['method'] = 'letsencrypt' - # This is really naive method of inferring if the cert is selfsigned and should never be used in production :) - elif data['subject'] == data['issuer']: - data['method'] = 'selfsigned' - return data +# Admin password tools def adminpwd_hash(password): return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() def adminpwd_verify(password, hash): return bcrypt.checkpw(password.encode(), hash.encode()) - -def shutdown_vm(): - subprocess.run(['/sbin/poweroff']) - -def reboot_vm(): - subprocess.run(['/sbin/reboot']) - -def update_hosts_lease(app, is_request): - # This is a poor man's DHCP server which uses /etc/hosts as lease database - # Leases the first unused IP from range 172.17.0.0/16 - # Uses file lock as interprocess mutex - ip = None - with open('/var/lock/vmmgr-hosts.lock', 'w') as lock: - fcntl.lockf(lock, fcntl.LOCK_EX) - # Load all existing records - with open('/etc/hosts', 'r') as f: - leases = [l.strip().split(' ', 1) for l in f] - # If this call is a request for lease, find the first unassigned IP - if is_request: - used_ips = [l[0] for l in leases] - for i in range(2, 65534): - ip = '172.17.{}.{}'. format(i // 256, i % 256) - if ip not in used_ips: - leases.append([ip, app]) - break - # Otherwise it is a release in which case we just delete the record - else: - leases = [l for l in leases if l[1] != app] - # Write the contents back to the file - with open('/etc/hosts', 'w') as f: - for lease in leases: - f.write('{} {}\n'.format(lease[0], lease[1])) - return ip - -def set_container_ip(pid, ip): - # Set IP in container based on PID given via lxc.hook.start-host hook - cmd = 'ip addr add {}/16 broadcast 172.17.255.255 dev eth0 && ip route add default via 172.17.0.1'.format(ip) - subprocess.run(['nsenter', '-a', '-t', pid, '--', '/bin/sh', '-c', cmd]) - -def clean_ephemeral_layer(app): - # Cleans containers ephemeral layer. - # This is done early in the container start process, so the inode of the delta0 directory must remain unchanged - layer = os.path.join('/var/lib/lxc', app, 'delta0') - if os.path.exists(layer): - for item in os.scandir(layer): - shutil.rmtree(item.path) if item.is_dir() else os.unlink(item.path) diff --git a/usr/lib/python3.6/vmmgr/vmmgr.py b/usr/lib/python3.6/vmmgr/vmmgr.py new file mode 100644 index 0000000..3e3b57b --- /dev/null +++ b/usr/lib/python3.6/vmmgr/vmmgr.py @@ -0,0 +1,218 @@ +# -*- coding: utf-8 -*- + +import fcntl +import os +import shutil +import subprocess + +from cryptography import x509 +from cryptography.hazmat.backends import default_backend +from cryptography.x509.oid import NameOID + +from . import templates +from . import tools +from .config import Config + +VERSION = '0.0.1' + +ISSUE_FILE = '/etc/issue' +NGINX_DIR = '/etc/nginx/conf.d' +ACME_CRON = '/etc/periodic/daily/acme-sh' +CERT_PUB_FILE = '/etc/ssl/services.pem' +CERT_KEY_FILE = '/etc/ssl/services.key' +CERT_SAN_FILE = '/etc/ssl/san.cnf' + +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(self, app, login, password): + # Write a file with credentials of a newly installed application which + # will be picked up by thread performing the installation after the install script finishes + login = login if login else 'N/A' + password = password if password else 'N/A' + with open('/tmp/{}.credentials'.format(app), 'w') as f: + f.write('{}\n{}'.format(login, password)) + + def update_host(self, domain, port): + # Update domain and port and rebuild all configuration. Web interface calls restart_nginx() in WSGI close handler + self.domain = self.conf['host']['domain'] = domain + self.port = self.conf['host']['port'] = port + self.conf.save() + # Rebuild and restart nginx if it was requested. + self.rebuild_nginx() + + def rebuild_nginx(self): + # 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 reload_nginx(self): + subprocess.run(['/usr/sbin/nginx', '-s', 'reload']) + + def restart_nginx(self): + subprocess.run(['/sbin/service', 'nginx', 'restart']) + + def rebuild_issue(self): + # Compile the URLs displayed in terminal banner and rebuild the file + with open(ISSUE_FILE, 'w') as f: + f.write(templates.ISSUE.format(url=tools.compile_url(self.domain, self.port), ip=tools.compile_url(tools.get_local_ip(), self.port))) + + def update_password(self, oldpassword, newpassword): + # Update LUKS password and adminpwd for WSGI application + input = '{}\n{}'.format(oldpassword, newpassword).encode() + subprocess.run(['cryptsetup', 'luksChangeKey', '/dev/sda2'], input=input, check=True) + # Update bcrypt-hashed password in config + self.conf['host']['adminpwd'] = tools.adminpwd_hash(newpassword) + # Save config to file + self.conf.save() + + def create_selfsigned_cert(self): + # Remove acme.sh cronjob + if os.path.exists(ACME_CRON): + os.unlink(ACME_CRON) + # Create selfsigned certificate with wildcard alternative subject name + with open(os.path.join(CERT_SAN_FILE), 'w') as f: + f.write(templates.CERT_SAN.format(domain=self.domain)) + subprocess.run(['openssl', 'req', '-config', CERT_SAN_FILE, '-x509', '-new', '-out', CERT_PUB_FILE, '-keyout', CERT_KEY_FILE, '-nodes', '-days', '7305', '-subj', '/CN={}'.format(self.domain)], check=True) + os.chmod(CERT_KEY_FILE, 0o640) + + def request_acme_cert(self): + # Remove all possible conflicting certificates requested in the past + certs = [i for i in os.listdir('/etc/acme.sh.d') if i not in ('account.conf', 'ca', 'http.header')] + for cert in certs: + if cert != self.domain: + subprocess.run(['/usr/bin/acme.sh', '--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('/etc/acme.sh.d', self.domain)): + cmd = ['/usr/bin/acme.sh', '--issue', '-d', self.domain] + for app in self.conf['apps'].copy(): + cmd += ['-d', '{}.{}'.format(self.conf['packages'][app]['host'], self.domain)] + cmd += ['-w', '/etc/acme.sh.d'] + # 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', '--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', '--install-cert', '-d', self.domain, '--key-file', CERT_KEY_FILE, '--fullchain-file', CERT_PUB_FILE, '--reloadcmd', '/sbin/service nginx reload'], check=True) + # Install acme.sh cronjob + with open(ACME_CRON, 'w') as f: + f.write(templates.ACME_CRON) + + def install_manual_cert(self, public_file, private_file): + # Remove acme.sh cronjob + if os.path.exists(ACME_CRON): + os.unlink(ACME_CRON) + # Copy certificate files + shutil.copyfile(public_file, CERT_PUB_FILE) + shutil.copyfile(private_file, CERT_KEY_FILE) + os.chmod(CERT_KEY_FILE, 0o640) + # Reload nginx + self.reload_nginx() + + def get_cert_info(self): + # Gather certificate data important for setup-host + with open(CERT_PUB_FILE, 'rb') as f: + 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), + 'method': 'manual'} + if os.path.exists('/etc/periodic/daily/acme-sh'): + data['method'] = 'letsencrypt' + # This is really naive method of inferring if the cert is selfsigned and should never be used in production :) + elif data['subject'] == data['issuer']: + data['method'] = 'selfsigned' + return data + + def prepare_container(self): + # Extract the variables from values given via lxc.hook.pre-start hook + app = os.environ['LXC_NAME'] + # Remove ephemeral layer data + self.clean_ephemeral_layer(app) + # Configure host and common params used in the app + self.configure_app(app) + + def clean_ephemeral_layer(self, app): + # Cleans containers ephemeral layer. + # This is done early in the container start process, so the inode of the delta0 directory must remain unchanged + layer = os.path.join('/var/lib/lxc', app, 'delta0') + if os.path.exists(layer): + for item in os.scandir(layer): + shutil.rmtree(item.path) if item.is_dir() else os.unlink(item.path) + + def register_container(self): + # Extract the variables from values given via lxc.hook.start-host hook + app = os.environ['LXC_NAME'] + pid = os.environ['LXC_PID'] + # Lease the first unused IP to the container + ip = self.update_hosts_lease(app, True) + # Set IP in container based on PID given via lxc.hook.start-host hook + cmd = 'ip addr add {}/16 broadcast 172.17.255.255 dev eth0 && ip route add default via 172.17.0.1'.format(ip) + subprocess.run(['nsenter', '-a', '-t', pid, '--', '/bin/sh', '-c', cmd]) + + def unregister_container(self): + # Extract the variables from values given via lxc.hook.post-stop hook + app = os.environ['LXC_NAME'] + # Release the container IP + self.update_hosts_lease(app, False) + # Remove ephemeral layer data + self.clean_ephemeral_layer(app) + + def update_hosts_lease(self, app, is_request): + # This is a poor man's DHCP server which uses /etc/hosts as lease database + # Leases the first unused IP from range 172.17.0.0/16 + # Uses file lock as interprocess mutex + ip = None + with open('/var/lock/vmmgr-hosts.lock', 'w') as lock: + fcntl.lockf(lock, fcntl.LOCK_EX) + # Load all existing records + with open('/etc/hosts', 'r') as f: + leases = [l.strip().split(' ', 1) for l in f] + # If this call is a request for lease, find the first unassigned IP + if is_request: + used_ips = [l[0] for l in leases] + for i in range(2, 65534): + ip = '172.17.{}.{}'. format(i // 256, i % 256) + if ip not in used_ips: + leases.append([ip, app]) + break + # Otherwise it is a release in which case we just delete the record + else: + leases = [l for l in leases if l[1] != app] + # Write the contents back to the file + with open('/etc/hosts', 'w') as f: + for lease in leases: + f.write('{} {}\n'.format(lease[0], lease[1])) + return ip + + def configure_app(self, app): + # Supply common configuration for the application. Done as part of container preparation during service startup + script = os.path.join('/srv', app, 'update-conf.sh') + if os.path.exists(script): + setup_env = os.environ.copy() + setup_env['DOMAIN'] = self.domain + setup_env['PORT'] = self.port + setup_env['EMAIL'] = self.conf['common']['email'] + setup_env['GMAPS_API_KEY'] = self.conf['common']['gmaps-api-key'] + subprocess.run([script], env=setup_env, check=True) + + 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['packages'][app]['host'], domain=self.domain, port=self.port)) + self.reload_nginx() + + def unregister_proxy(self, app): + # Remove proxy configuration and reload nginx + os.unlink(os.path.join(NGINX_DIR, '{}.conf'.format(app))) + self.reload_nginx() diff --git a/usr/lib/python3.6/vmmgr/wsgiapp.py b/usr/lib/python3.6/vmmgr/wsgiapp.py index bdd5de8..1cc4e7b 100644 --- a/usr/lib/python3.6/vmmgr/wsgiapp.py +++ b/usr/lib/python3.6/vmmgr/wsgiapp.py @@ -12,12 +12,12 @@ from jinja2 import Environment, FileSystemLoader from cryptography.exceptions import InvalidSignature -from . import VMMgr, CERT_PUB_FILE from . import tools from . import validator from .actionqueue import ActionQueue from .appmgr import AppMgr from .config import Config +from .vmmgr import VMMgr from .wsgilang import WSGILang from .wsgisession import WSGISession @@ -151,17 +151,19 @@ class WSGIApp(object): ex_ipv6 = tools.get_external_ip(6) in_ipv4 = tools.get_local_ip(4) in_ipv6 = tools.get_local_ip(6) - cert_info = tools.get_cert_info(CERT_PUB_FILE) + cert_info = self.vmmgr.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) def setup_apps_view(self, request): # Application manager view. repo_error = None try: - self.appmgr.fetch_online_packages() + status = self.appmgr.fetch_online_packages() except InvalidSignature: - repo_error = request.session.lang.invalild_packages_signature() - except: + repo_error = request.session.lang.invalid_packages_signature() + if status in (401, 403): + repo_error = request.session.lang.repo_invalid_credentials() + elif status != 200: repo_error = request.session.lang.repo_unavailable() table = self.render_setup_apps_table(request) message = self.get_session_message(request) @@ -176,7 +178,7 @@ class WSGIApp(object): installed = app in self.conf['apps'] title = self.conf['packages'][app]['title'] if installed else self.appmgr.online_packages[app]['title'] visible = self.conf['apps'][app]['visible'] if installed else False - autostarted = tools.is_service_autostarted(app) if installed else False + autostarted = self.appmgr.is_service_autostarted(app) if installed else False if app in pending_actions: item = pending_actions[app] actions = '
' @@ -221,7 +223,7 @@ class WSGIApp(object): if not installed: status = lang.status_not_installed() actions = '{}'.format(lang.action_install()) - elif tools.is_service_started(app): + elif self.appmgr.is_service_started(app): status = '{}'.format(lang.status_started()) actions = '{}'.format(lang.action_stop()) else: @@ -234,14 +236,14 @@ class WSGIApp(object): # Update domain and port, then restart nginx domain = request.form['domain'] port = request.form['port'] - if not validator.is_valid_domain(domain) + if not validator.is_valid_domain(domain): return self.render_json({'error': request.session.lang.invalid_domain(domain)}) elif not validator.is_valid_port(port): return self.render_json({'error': request.session.lang.invalid_port(port)}) self.vmmgr.update_host(domain, port) url = '{}/setup-host'.format(tools.compile_url(tools.get_local_ip(), port)) response = self.render_json({'ok': request.session.lang.host_updated(url, url)}) - response.call_on_close(tools.restart_nginx) + response.call_on_close(self.vmmgr.restart_nginx) return response def verify_dns_action(self, request): @@ -376,14 +378,14 @@ class WSGIApp(object): def reboot_vm_action(self, request): # Reboots VM response = self.render_json({'ok': request.session.lang.reboot_initiated()}) - response.call_on_close(tools.reboot_vm) + response.call_on_close(self.appmgr.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(tools.shutdown_vm) + response.call_on_close(self.appmgr.shutdown_vm) return response def is_app_visible(self, app): - return app in self.conf['apps'] and self.conf['apps'][app]['visible'] and tools.is_service_started(app) + return app in self.conf['apps'] and self.conf['apps'][app]['visible'] and self.appmgr.is_service_started(app) diff --git a/usr/lib/python3.6/vmmgr/wsgilang.py b/usr/lib/python3.6/vmmgr/wsgilang.py index 73b699a..b023bd6 100644 --- a/usr/lib/python3.6/vmmgr/wsgilang.py +++ b/usr/lib/python3.6/vmmgr/wsgilang.py @@ -24,7 +24,8 @@ class WSGILang: 'installation_in_progress': 'Probíhá instalace jiného balíku. Vyčkejte na její dokončení.', 'package_manager_error': 'Došlo k chybě při instalaci aplikace. Zkuste akci opakovat nebo restartuje virtuální stroj.', 'invalid_packages_signature': 'Digitální podpis seznamu balíků není platný. Kontaktujte správce distribučního serveru.', - 'repo_unavailable': 'Připojení k distribučnímu serveru se nezdařilo. Zkontrolujte přístupové údaje a připojení k síti.', + 'repo_invalid_credentials': 'Přístupové údaje k distribučnímu serveru nejsou správné.', + 'repo_unavailable': 'Distribuční server není dostupný. Zkontroluje připojení k síti', 'bad_password': 'Nesprávné heslo', 'password_mismatch': 'Zadaná hesla se neshodují', 'password_empty': 'Nové heslo nesmí být prázdné', diff --git a/usr/share/vmmgr/wsgi.py b/usr/share/vmmgr/wsgi.py index a289602..d2ad901 100755 --- a/usr/share/vmmgr/wsgi.py +++ b/usr/share/vmmgr/wsgi.py @@ -1,8 +1,7 @@ #!/usr/bin/python3 # -*- coding: utf-8 -*- -import sys -from vmmgr.wsgiapp import WSGIApp +from vmmgr import WSGIApp application = WSGIApp()