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

345 lines
13 KiB
Python
Raw Normal View History

2018-08-02 10:41:40 +02:00
# -*- coding: utf-8 -*-
import json
import os
import shutil
import subprocess
from . import confupdater
from . import tools
from . import validator
VERSION = '0.0.1'
2018-09-03 17:24:48 +02:00
CONF_FILE = '/srv/vm/config.json'
2018-08-02 10:41:40 +02:00
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'
2018-08-02 10:41:40 +02:00
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 {{
2018-09-03 17:24:48 +02:00
root /srv/vm/templates;
2018-08-02 10:41:40 +02:00
}}
2018-09-03 17:24:48 +02:00
location = /vm-ping {{
2018-08-02 10:41:40 +02:00
add_header Content-Type text/plain;
2018-09-03 17:24:48 +02:00
return 200 "vm-pong";
2018-08-02 10:41:40 +02:00
}}
}}
'''
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;
}}
2018-09-03 17:24:48 +02:00
location = /vm-ping {{
2018-08-02 10:41:40 +02:00
add_header Content-Type text/plain;
2018-09-03 17:24:48 +02:00
return 200 "vm-pong";
2018-08-02 10:41:40 +02:00
}}
}}
server {{
listen [::]:{port} ssl http2 default_server ipv6only=off;
location / {{
proxy_pass http://127.0.0.1:8080;
}}
location /static {{
2018-09-03 17:24:48 +02:00
root /srv/vm;
2018-08-02 10:41:40 +02:00
}}
error_page 502 /502.html;
location = /502.html {{
2018-09-03 17:24:48 +02:00
root /srv/vm/templates;
2018-08-02 10:41:40 +02:00
}}
2018-09-03 17:24:48 +02:00
location = /vm-ping {{
2018-08-02 10:41:40 +02:00
add_header Content-Type text/plain;
2018-09-03 17:24:48 +02:00
return 200 "vm-pong";
2018-08-02 10:41:40 +02:00
}}
}}
'''
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
2018-08-02 10:41:40 +02:00
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}"
'''
2018-09-04 21:42:26 +02:00
class VMMgr:
2018-08-02 10:41:40 +02:00
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()
2018-08-02 10:41:40 +02:00
def rebuild_issue(self):
# Compile the HTTPS host displayed in terminal banner
domain = self.domain
2018-08-02 10:41:40 +02:00
# 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'
2018-08-02 10:41:40 +02:00
# Rebuild the terminal banner
with open(ISSUE_FILE, 'w') as f:
f.write(ISSUE_TEMPLATE.format(url=tools.compile_url(domain, self.port)))
2018-08-02 10:41:40 +02:00
def update_apps_urls(self):
# Update configuration for respective applications
confupdater.update_url(tools.compile_url(self.domain, self.port, None))
2018-08-02 10:41:40 +02:00
# 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)
2018-08-02 10:41:40 +02:00
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()