Spotter-VM/basic/srv/vm/mgr/__init__.py

372 lines
14 KiB
Python

# -*- 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()