365 lines
13 KiB
Python
365 lines
13 KiB
Python
|
# -*- coding: utf-8 -*-
|
||
|
|
||
|
import os
|
||
|
import shutil
|
||
|
import subprocess
|
||
|
|
||
|
from . import tools
|
||
|
from . import validator
|
||
|
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'
|
||
|
|
||
|
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
|
||
|
self.conf = Config()
|
||
|
self.domain = self.conf['host']['domain']
|
||
|
self.port = self.conf['host']['port']
|
||
|
|
||
|
def update_login(self, app, login, password):
|
||
|
# Update login and password for an app in the configuration
|
||
|
if app not in self.conf['apps']:
|
||
|
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.conf.save()
|
||
|
|
||
|
def show_tiles(self, app):
|
||
|
# Update visibility for the app in the configuration
|
||
|
if app not in self.conf['apps']:
|
||
|
raise validator.InvalidValueException('app', app)
|
||
|
self.conf['apps'][app]['visible'] = True
|
||
|
self.conf.save()
|
||
|
|
||
|
def hide_tiles(self, app):
|
||
|
# Update visibility for the app in the configuration
|
||
|
if app not in self.conf['apps']:
|
||
|
raise validator.InvalidValueException('app', app)
|
||
|
self.conf['apps'][app]['visible'] = False
|
||
|
self.conf.save()
|
||
|
|
||
|
def start_app(self, app):
|
||
|
# Start the actual app service
|
||
|
if app not in self.conf['apps']:
|
||
|
raise validator.InvalidValueException('app', app)
|
||
|
tools.start_service(app)
|
||
|
|
||
|
def stop_app(self, app):
|
||
|
# Stop the actual app service
|
||
|
if app not in self.conf['apps']:
|
||
|
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 app not in self.conf['apps']:
|
||
|
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 app not in self.conf['apps']:
|
||
|
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 app not in self.conf['apps']:
|
||
|
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 app not in self.conf['apps']:
|
||
|
raise validator.InvalidValueException('app', app)
|
||
|
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
|
||
|
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.conf.save()
|
||
|
# 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.
|
||
|
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))
|
||
|
|
||
|
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.conf.save()
|
||
|
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.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']:
|
||
|
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_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()
|