# -*- coding: utf-8 -*- import json import os import shutil import subprocess from . import confupdater from . import tools from . import validator VERSION = '0.0.1' CONF_FILE = '/srv/spotter/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://{ip}:8080; }} error_page 502 /502.html; location = /502.html {{ root /srv/spotter/templates; }} location = /spotter-ping {{ add_header Content-Type text/plain; return 200 "spotter-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 = /spotter-ping {{ add_header Content-Type text/plain; return 200 "spotter-pong"; }} }} server {{ listen [::]:{port} ssl http2 default_server ipv6only=off; location / {{ proxy_pass http://127.0.0.1:8080; }} location /static {{ root /srv/spotter; }} error_page 502 /502.html; location = /502.html {{ root /srv/spotter/templates; }} location = /spotter-ping {{ add_header Content-Type text/plain; return 200 "spotter-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 AppMgr: 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, skipping first two elements (docker, net) try: with open(os.path.join('/etc/init.d', app), 'r') as f: return [l.split()[2:] for l in f.readlines() if l.startswith('\tneed')][0] except: 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 register_proxy(self, app): # Rebuild nginx configuration using IP of referenced app container and reload nginx if not validator.is_valid_app(app, self.conf): raise validator.InvalidValueException('app', app) self.update_proxy_conf(app, tools.get_container_ip(app)) tools.reload_nginx() def update_proxy_conf(self, app, ip): 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'], ip=ip, domain=self.domain, port=self.port)) def unregister_proxy(self, app): # Remove nginx configuration to prevent proxy mismatch when the container IP is reassigned to another container if not validator.is_valid_app(app, self.conf): raise validator.InvalidValueException('app', app) self.update_proxy_conf(app, tools.NULL_IP) 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() self.rebuild_nginx(restart_nginx) self.update_apps_urls() 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)) # Unregister nginx proxy for apps (will be repopulated on app restart) for app in self.conf['apps']: self.update_proxy_conf(app, tools.NULL_IP) # 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_apps_urls(self): # Update configuration for respective applications confupdater.update_url(tools.compile_url(self.domain, self.port, None)) # Restart currently running apps in order to update config and re-register nginx proxy for app in self.conf['apps']: if tools.is_service_started(app): tools.restart_service(app) 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 confupdater.update_email(email) if gmaps_api_key != None: # Update Google Maps API key self.conf['common']['gmaps-api-key'] = gmaps_api_key confupdater.update_gmaps_api_key(gmaps_api_key) # Save config to file self.save_conf() # Restart currently running apps in order to update config for app in self.conf['apps']: if tools.is_service_started(app): tools.restart_service(app) def update_password(self, oldpassword, newpassword): # Update LUKS password and adminpwd for WSGI application tools.update_luks_password(oldpassword, newpassword) 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()