# -*- coding: utf-8 -*- import json import os import shutil import subprocess from . import tools from . import validator VERSION = '0.0.1' CONF_FILE = '/srv/vm/config.json' 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 /srv/vm/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 /srv/vm; }} error_page 502 /502.html; location = /502.html {{ root /srv/vm/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 URL \x1b[1m{url}\x1b[0m ve Vašem internetovém prohlížeči. \x1b[0;30m ''' 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): # Load JSON configuration with open(CONF_FILE, 'r') as f: self.conf = json.load(f) self.domain = self.conf['host']['domain'] self.port = self.conf['host']['port'] def save_conf(self): # Save a sorted JSON configuration object with indentation with open(CONF_FILE, 'w') as f: json.dump(self.conf, f, sort_keys=True, indent=4) def update_login(self, app, login, password): # Update login and password for an app in the configuration if not validator.is_valid_app(app, self.conf): raise validator.InvalidValueException('app', app) if login is not None: self.conf['apps'][app]['login'] = login if password is not None: self.conf['apps'][app]['password'] = password self.save_conf() def show_tiles(self, app): # Update visibility for the app in the configuration if not validator.is_valid_app(app, self.conf): raise validator.InvalidValueException('app', app) self.conf['apps'][app]['visible'] = True self.save_conf() def hide_tiles(self, app): # Update visibility for the app in the configuration if not validator.is_valid_app(app, self.conf): raise validator.InvalidValueException('app', app) self.conf['apps'][app]['visible'] = False self.save_conf() def start_app(self, app): # Start the actual app service if not validator.is_valid_app(app, self.conf): raise validator.InvalidValueException('app', app) tools.start_service(app) def stop_app(self, app): # Stop the actual app service if not validator.is_valid_app(app, self.conf): raise validator.InvalidValueException('app', app) tools.stop_service(app) # Stop the app service's dependencies if they are not used by another running app deps = self.build_deps_tree() for dep in self.get_app_deps(app): if not any([tools.is_service_started(d) for d in deps[dep]]): tools.stop_service(dep) def build_deps_tree(self): # Fisrt, build a dictionary of {app: [needs]} needs = {} for app in self.conf['apps']: needs[app] = self.get_app_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_app_deps(self, 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 enable_autostart(self, app): # Add the app to OpenRC default runlevel if not validator.is_valid_app(app, self.conf): raise validator.InvalidValueException('app', app) subprocess.run(['/sbin/rc-update', 'add', app]) def disable_autostart(self, app): # Remove the app from OpenRC default runlevel if not validator.is_valid_app(app, self.conf): raise validator.InvalidValueException('app', app) subprocess.run(['/sbin/rc-update', 'del', app]) 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.get_unused_ip() tools.update_hosts_lease(ip, app) 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(None, app) # 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 if not validator.is_valid_app(app, self.conf): raise validator.InvalidValueException('app', app) with open(os.path.join(NGINX_DIR, '{}.conf'.format(app)), 'w') as f: f.write(NGINX_TEMPLATE.format(app=app, host=self.conf['apps'][app]['host'], domain=self.domain, port=self.port)) tools.reload_nginx() def unregister_proxy(self, app): # Remove proxy configuration and reload nginx if not validator.is_valid_app(app, self.conf): raise validator.InvalidValueException('app', app) os.unlink(os.path.join(NGINX_DIR, '{}.conf'.format(app))) tools.reload_nginx() def update_host(self, domain, port, restart_nginx=True): # Update domain and port and rebuild all configurtion. Defer nginx restart when updating from web interface if not validator.is_valid_domain(domain): raise validator.InvalidValueException('domain', domain) if not validator.is_valid_port(port): raise validator.InvalidValueException('port', port) self.domain = self.conf['host']['domain'] = domain self.port = self.conf['host']['port'] = port self.save_conf() # Restart all apps to trigger configuration refresh for app in self.conf['apps']: if tools.is_service_started(app): tools.restart_service(app) # Rebuild and restart nginx if it was requested. Web interface calls tools.restart_nginx() in WSGI close handler self.rebuild_nginx(restart_nginx) def rebuild_nginx(self, restart_nginx): # Rebuild nginx config for the portal app with open(os.path.join(NGINX_DIR, 'default.conf'), 'w') as f: f.write(NGINX_DEFAULT_TEMPLATE.format(port=self.port)) # Restart nginx to properly bind the new listen port if restart_nginx: tools.restart_nginx() def rebuild_issue(self): # Compile the HTTPS host displayed in terminal banner domain = self.domain # If the dummy host is used, take an IP address of a primary interface instead if domain == 'spotter.vm': domain = tools.get_local_ipv4() if not domain: domain = tools.get_local_ipv6() if not domain: domain = '127.0.0.1' # Rebuild the terminal banner with open(ISSUE_FILE, 'w') as f: f.write(ISSUE_TEMPLATE.format(url=tools.compile_url(domain, self.port))) def update_common(self, email, gmaps_api_key): # Update common configuration values if email != None: # Update email if not validator.is_valid_email(email): raise validator.InvalidValueException('email', email) self.conf['common']['email'] = email if gmaps_api_key != None: # Update Google Maps API key self.conf['common']['gmaps-api-key'] = gmaps_api_key # Save config to file self.save_conf() for app in self.conf['apps']: # Restart currently running apps in order to update their config if tools.is_service_started(app): tools.restart_service(app) 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.save_conf() def create_selfsigned(self): # 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_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']: cmd += ['-d', '{}.{}'.format(self.conf['apps'][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_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()