Move tools into best fitting modules

This commit is contained in:
Disassembler 2018-11-05 14:41:10 +01:00
parent 75e86b0dcb
commit c2b383e5c8
No known key found for this signature in database
GPG Key ID: 524BD33A0EE29499
9 changed files with 428 additions and 409 deletions

View File

@ -2,9 +2,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import argparse import argparse
import sys from vmmgr import Config, VMMgr
from vmmgr import VMMgr
from vmmgr.config import Config
parser = argparse.ArgumentParser(description='VM application manager') parser = argparse.ArgumentParser(description='VM application manager')
subparsers = parser.add_subparsers() subparsers = parser.add_subparsers()

View File

@ -1,279 +1,13 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import os from .appmgr import AppMgr
import shutil
import subprocess
from . import tools
from .config import Config from .config import Config
from .vmmgr import VMMgr
from .wsgiapp import WSGIApp
VERSION = '0.0.1' __all__ = [
'AppMgr',
ISSUE_FILE = '/etc/issue' 'Config',
NGINX_DIR = '/etc/nginx/conf.d' 'VMMgr',
ACME_CRON = '/etc/periodic/daily/acme-sh' 'WSGIApp'
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 /usr/share/vmmgr/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 /usr/share/vmmgr;
}}
error_page 502 /502.html;
location = /502.html {{
root /usr/share/vmmgr/templates;
}}
location = /vm-ping {{
add_header Content-Type text/plain;
return 200 "vm-pong";
}}
}}
server {{
listen [::]:{port} ssl http2;
server_name ~^(.*)\.{domain_esc}$;
location / {{
return 503;
}}
location /static {{
root /usr/share/vmmgr;
}}
error_page 503 /503.html;
location = /503.html {{
root /usr/share/vmmgr/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 jednu z těcho URL v internetovém prohlížeči.
Open one the following URLs in web browser to access the applications.
- \x1b[1m{url}\x1b[0m
- \x1b[1m{ip}\x1b[0m\x1b[?1c
'''
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, conf):
# Load JSON configuration
self.conf = conf
self.domain = conf['host']['domain']
self.port = conf['host']['port']
def register_app(self, app, login, password):
# Write a file with credentials of a newly installed application which
# will be picked up by thread performing the installation after the install script finishes
login = login if login else 'N/A'
password = password if password else 'N/A'
with open('/tmp/{}.credentials'.format(app), 'w') as f:
f.write('{}\n{}'.format(login, password))
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.update_hosts_lease(app, True)
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(app, False)
# 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
with open(os.path.join(NGINX_DIR, '{}.conf'.format(app)), 'w') as f:
f.write(NGINX_TEMPLATE.format(app=app, host=self.conf['packages'][app]['host'], domain=self.domain, port=self.port))
tools.reload_nginx()
def unregister_proxy(self, app):
# Remove proxy configuration and reload nginx
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
self.domain = self.conf['host']['domain'] = domain
self.port = self.conf['host']['port'] = port
self.conf.save()
# 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, domain_esc=self.domain.replace('.', '\.')))
def rebuild_issue(self):
# Compile the URLs displayed in terminal banner and rebuild the file
with open(ISSUE_FILE, 'w') as f:
f.write(ISSUE_TEMPLATE.format(url=tools.compile_url(self.domain, self.port), ip=tools.compile_url(tools.get_local_ip(), self.port)))
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'].copy():
cmd += ['-d', '{}.{}'.format(self.conf['packages'][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()

View File

@ -40,7 +40,10 @@ class AppMgr:
def fetch_online_packages(self): def fetch_online_packages(self):
# Fetches and verifies online packages. Can raise InvalidSignature # Fetches and verifies online packages. Can raise InvalidSignature
online_packages = {} online_packages = {}
packages = self.get_repo_resource('packages').content packages = self.get_repo_resource('packages')
if packages.status_code != 200:
return packages.status_code
packages = packages.content
packages_sig = self.get_repo_resource('packages.sig').content packages_sig = self.get_repo_resource('packages.sig').content
with open(PUB_FILE, 'rb') as f: with open(PUB_FILE, 'rb') as f:
pub_key = load_pem_public_key(f.read(), default_backend()) pub_key = load_pem_public_key(f.read(), default_backend())
@ -48,23 +51,30 @@ class AppMgr:
online_packages = json.loads(packages) online_packages = json.loads(packages)
# Minimze the time when self.online_packages is out of sync # Minimze the time when self.online_packages is out of sync
self.online_packages = online_packages self.online_packages = online_packages
return 200
def start_app(self, item): def start_app(self, item):
# Start the actual app service # Start the actual app service
app = item.key app = item.key
if app in self.conf['apps'] and not tools.is_service_started(app): if app in self.conf['apps'] and not self.is_service_started(app):
tools.start_service(app) self.start_service(app)
def start_service(self, service):
subprocess.run(['/sbin/service', service, 'start'], check=True)
def stop_app(self, item): def stop_app(self, item):
# Stop the actual app service # Stop the actual app service
app = item.key app = item.key
if app in self.conf['apps'] and tools.is_service_started(app): if app in self.conf['apps'] and self.is_service_started(app):
tools.stop_service(app) self.stop_service(app)
# Stop the app service's dependencies if they are not used by another running app # Stop the app service's dependencies if they are not used by another running app
deps = self.get_services_deps() deps = self.get_services_deps()
for dep in tools.get_service_deps(app): for dep in self.get_service_deps(app):
if not any([tools.is_service_started(d) for d in deps[dep]]): if not any([self.is_service_started(d) for d in deps[dep]]):
tools.stop_service(dep) self.stop_service(dep)
def stop_service(self, service):
subprocess.run(['/sbin/service', service, 'stop'], check=True)
def update_app_visibility(self, app, visible): def update_app_visibility(self, app, visible):
# Update visibility for the app in the configuration # Update visibility for the app in the configuration
@ -77,6 +87,14 @@ class AppMgr:
if app in self.conf['apps']: if app in self.conf['apps']:
subprocess.run(['/sbin/rc-update', 'add' if enabled else 'del', app]) subprocess.run(['/sbin/rc-update', 'add' if enabled else 'del', app])
def is_service_started(self, app):
# Check OpenRC service status without calling any binary
return os.path.exists(os.path.join('/run/openrc/started', app))
def is_service_autostarted(self, app):
# Check OpenRC service enablement
return os.path.exists(os.path.join('/etc/runlevels/default', app))
def install_app(self, item): def install_app(self, item):
# Main installation function. Wrapper for download, registration and install script # Main installation function. Wrapper for download, registration and install script
app = item.key app = item.key
@ -102,7 +120,7 @@ class AppMgr:
# Main uninstallation function. Wrapper for uninstall script, filesystem purge and unregistration # Main uninstallation function. Wrapper for uninstall script, filesystem purge and unregistration
app = item.key app = item.key
self.stop_app(item) self.stop_app(item)
if tools.is_service_autostarted(app): if self.is_service_autostarted(app):
self.update_app_autostart(app, False) self.update_app_autostart(app, False)
deps = self.get_install_deps(app, False)[::-1] deps = self.get_install_deps(app, False)[::-1]
for dep in deps: for dep in deps:
@ -119,12 +137,23 @@ class AppMgr:
if chunk: if chunk:
installitem.downloaded += f.write(chunk) installitem.downloaded += f.write(chunk)
# Verify hash # Verify hash
if self.online_packages[name]['sha512'] != hash_file(tmp_archive): if self.online_packages[name]['sha512'] != self.hash_file(tmp_archive):
raise InvalidSignature(name) raise InvalidSignature(name)
def hash_file(self, file_path):
sha512 = hashlib.sha512()
with open(file_path, 'rb') as f:
while True:
data = f.read(65536)
if not data:
break
sha512.update(data)
return sha512.hexdigest()
def unpack_package(self, name): def unpack_package(self, name):
# Unpack archive # Unpack archive
tmp_archive = '/tmp/{}.tar.xz'.format(name) tmp_archive = '/tmp/{}.tar.xz'.format(name)
print('Unpacking', ['tar', 'xJf', tmp_archive])
subprocess.run(['tar', 'xJf', tmp_archive], cwd='/', check=True) subprocess.run(['tar', 'xJf', tmp_archive], cwd='/', check=True)
os.unlink(tmp_archive) os.unlink(tmp_archive)
@ -218,7 +247,7 @@ class AppMgr:
# Fisrt, build a dictionary of {app: [needs]} # Fisrt, build a dictionary of {app: [needs]}
needs = {} needs = {}
for app in self.conf['apps'].copy(): for app in self.conf['apps'].copy():
needs[app] = tools.get_service_deps(app) needs[app] = self.get_service_deps(app)
# Then reverse it to {need: [apps]} # Then reverse it to {need: [apps]}
deps = {} deps = {}
for app, need in needs.items(): for app, need in needs.items():
@ -226,6 +255,15 @@ class AppMgr:
deps.setdefault(n, []).append(app) deps.setdefault(n, []).append(app)
return deps return deps
def get_service_deps(self, app):
# Get "need" line from init script and split it to list
try:
with open(os.path.join('/etc/init.d', app), 'r') as f:
return [l for l in f.readlines() if l.strip().startswith('need')][0].split()[1:]
except:
pass
return []
def update_common_settings(self, email, gmaps_api_key): def update_common_settings(self, email, gmaps_api_key):
# Update common configuration values # Update common configuration values
self.conf['common']['email'] = email self.conf['common']['email'] = email
@ -239,12 +277,8 @@ class AppMgr:
self.conf['repo']['pwd'] = pwd self.conf['repo']['pwd'] = pwd
self.conf.save() self.conf.save()
def hash_file(file_path): def shutdown_vm(self):
sha512 = hashlib.sha512() subprocess.run(['/sbin/poweroff'])
with open(file_path, 'rb') as f:
while True: def reboot_vm(self):
data = f.read(65536) subprocess.run(['/sbin/reboot'])
if not data:
break
sha512.update(data)
return sha512.hexdigest()

View File

@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
NGINX = '''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 /usr/share/vmmgr/templates;
}}
location = /vm-ping {{
add_header Content-Type text/plain;
return 200 "vm-pong";
}}
}}
'''
NGINX_DEFAULT = '''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 /usr/share/vmmgr;
}}
error_page 502 /502.html;
location = /502.html {{
root /usr/share/vmmgr/templates;
}}
location = /vm-ping {{
add_header Content-Type text/plain;
return 200 "vm-pong";
}}
}}
server {{
listen [::]:{port} ssl http2;
server_name ~^(.*)\.{domain_esc}$;
location / {{
return 503;
}}
location /static {{
root /usr/share/vmmgr;
}}
error_page 503 /503.html;
location = /503.html {{
root /usr/share/vmmgr/templates;
}}
location = /vm-ping {{
add_header Content-Type text/plain;
return 200 "vm-pong";
}}
}}
'''
ISSUE = '''
\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 jednu z těcho URL v internetovém prohlížeči.
Open one the following URLs in web browser to access the applications.
- \x1b[1m{url}\x1b[0m
- \x1b[1m{ip}\x1b[0m\x1b[?1c
'''
ACME_CRON = '''#!/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}"
'''

View File

@ -3,16 +3,12 @@
import bcrypt import bcrypt
import dns.exception import dns.exception
import dns.resolver import dns.resolver
import fcntl
import os import os
import requests import requests
import shutil
import socket import socket
import subprocess import subprocess
from cryptography import x509 # Network tools
from cryptography.hazmat.backends import default_backend
from cryptography.x509.oid import NameOID
def compile_url(domain, port, proto='https'): def compile_url(domain, port, proto='https'):
port = '' if (proto == 'https' and port == '443') or (proto == 'http' and port == '80') else ':{}'.format(port) port = '' if (proto == 'https' and port == '443') or (proto == 'http' and port == '80') else ':{}'.format(port)
@ -64,100 +60,10 @@ def ping_url(url):
except: except:
return False return False
def get_service_deps(app): # Admin password tools
# 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 is_service_started(app):
# Check OpenRC service status without calling any binary
return os.path.exists(os.path.join('/run/openrc/started', app))
def is_service_autostarted(app):
# Check OpenRC service enablement
return os.path.exists(os.path.join('/etc/runlevels/default', app))
def start_service(service):
subprocess.run(['/sbin/service', service, 'start'], check=True)
def stop_service(service):
subprocess.run(['/sbin/service', service, 'stop'], check=True)
def reload_nginx():
subprocess.run(['/usr/sbin/nginx', '-s', 'reload'])
def restart_nginx():
restart_service(['/sbin/service', 'nginx', 'restart'])
def get_cert_info(cert):
# Gather certificate data important for setup-host
with open(cert, 'rb') as f:
cert = x509.load_pem_x509_certificate(f.read(), default_backend())
data = {'subject': cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value,
'issuer': cert.issuer.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value,
'expires': '{} UTC'.format(cert.not_valid_after),
'method': 'manual'}
if os.path.exists('/etc/periodic/daily/acme-sh'):
data['method'] = 'letsencrypt'
# This is really naive method of inferring if the cert is selfsigned and should never be used in production :)
elif data['subject'] == data['issuer']:
data['method'] = 'selfsigned'
return data
def adminpwd_hash(password): def adminpwd_hash(password):
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def adminpwd_verify(password, hash): def adminpwd_verify(password, hash):
return bcrypt.checkpw(password.encode(), hash.encode()) return bcrypt.checkpw(password.encode(), hash.encode())
def shutdown_vm():
subprocess.run(['/sbin/poweroff'])
def reboot_vm():
subprocess.run(['/sbin/reboot'])
def update_hosts_lease(app, is_request):
# This is a poor man's DHCP server which uses /etc/hosts as lease database
# Leases the first unused IP from range 172.17.0.0/16
# Uses file lock as interprocess mutex
ip = None
with open('/var/lock/vmmgr-hosts.lock', 'w') as lock:
fcntl.lockf(lock, fcntl.LOCK_EX)
# Load all existing records
with open('/etc/hosts', 'r') as f:
leases = [l.strip().split(' ', 1) for l in f]
# If this call is a request for lease, find the first unassigned IP
if is_request:
used_ips = [l[0] for l in leases]
for i in range(2, 65534):
ip = '172.17.{}.{}'. format(i // 256, i % 256)
if ip not in used_ips:
leases.append([ip, app])
break
# Otherwise it is a release in which case we just delete the record
else:
leases = [l for l in leases if l[1] != app]
# Write the contents back to the file
with open('/etc/hosts', 'w') as f:
for lease in leases:
f.write('{} {}\n'.format(lease[0], lease[1]))
return ip
def set_container_ip(pid, ip):
# Set IP in container based on PID given via lxc.hook.start-host hook
cmd = 'ip addr add {}/16 broadcast 172.17.255.255 dev eth0 && ip route add default via 172.17.0.1'.format(ip)
subprocess.run(['nsenter', '-a', '-t', pid, '--', '/bin/sh', '-c', cmd])
def clean_ephemeral_layer(app):
# Cleans containers ephemeral layer.
# This is done early in the container start process, so the inode of the delta0 directory must remain unchanged
layer = os.path.join('/var/lib/lxc', app, 'delta0')
if os.path.exists(layer):
for item in os.scandir(layer):
shutil.rmtree(item.path) if item.is_dir() else os.unlink(item.path)

View File

@ -0,0 +1,218 @@
# -*- coding: utf-8 -*-
import fcntl
import os
import shutil
import subprocess
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.x509.oid import NameOID
from . import templates
from . import tools
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'
class VMMgr:
def __init__(self, conf):
# Load JSON configuration
self.conf = conf
self.domain = conf['host']['domain']
self.port = conf['host']['port']
def register_app(self, app, login, password):
# Write a file with credentials of a newly installed application which
# will be picked up by thread performing the installation after the install script finishes
login = login if login else 'N/A'
password = password if password else 'N/A'
with open('/tmp/{}.credentials'.format(app), 'w') as f:
f.write('{}\n{}'.format(login, password))
def update_host(self, domain, port):
# Update domain and port and rebuild all configuration. Web interface calls restart_nginx() in WSGI close handler
self.domain = self.conf['host']['domain'] = domain
self.port = self.conf['host']['port'] = port
self.conf.save()
# 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 restart_nginx() in WSGI close handler
with open(os.path.join(NGINX_DIR, 'default.conf'), 'w') as f:
f.write(templates.NGINX_DEFAULT.format(port=self.port, domain_esc=self.domain.replace('.', '\.')))
def reload_nginx(self):
subprocess.run(['/usr/sbin/nginx', '-s', 'reload'])
def restart_nginx(self):
subprocess.run(['/sbin/service', 'nginx', 'restart'])
def rebuild_issue(self):
# Compile the URLs displayed in terminal banner and rebuild the file
with open(ISSUE_FILE, 'w') as f:
f.write(templates.ISSUE.format(url=tools.compile_url(self.domain, self.port), ip=tools.compile_url(tools.get_local_ip(), self.port)))
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(templates.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'].copy():
cmd += ['-d', '{}.{}'.format(self.conf['packages'][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(templates.ACME_CRON)
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
self.reload_nginx()
def get_cert_info(self):
# Gather certificate data important for setup-host
with open(CERT_PUB_FILE, 'rb') as f:
cert = x509.load_pem_x509_certificate(f.read(), default_backend())
data = {'subject': cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value,
'issuer': cert.issuer.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value,
'expires': '{} UTC'.format(cert.not_valid_after),
'method': 'manual'}
if os.path.exists('/etc/periodic/daily/acme-sh'):
data['method'] = 'letsencrypt'
# This is really naive method of inferring if the cert is selfsigned and should never be used in production :)
elif data['subject'] == data['issuer']:
data['method'] = 'selfsigned'
return data
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
self.clean_ephemeral_layer(app)
# Configure host and common params used in the app
self.configure_app(app)
def clean_ephemeral_layer(self, app):
# Cleans containers ephemeral layer.
# This is done early in the container start process, so the inode of the delta0 directory must remain unchanged
layer = os.path.join('/var/lib/lxc', app, 'delta0')
if os.path.exists(layer):
for item in os.scandir(layer):
shutil.rmtree(item.path) if item.is_dir() else os.unlink(item.path)
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 = self.update_hosts_lease(app, True)
# Set IP in container based on PID given via lxc.hook.start-host hook
cmd = 'ip addr add {}/16 broadcast 172.17.255.255 dev eth0 && ip route add default via 172.17.0.1'.format(ip)
subprocess.run(['nsenter', '-a', '-t', pid, '--', '/bin/sh', '-c', cmd])
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
self.update_hosts_lease(app, False)
# Remove ephemeral layer data
self.clean_ephemeral_layer(app)
def update_hosts_lease(self, app, is_request):
# This is a poor man's DHCP server which uses /etc/hosts as lease database
# Leases the first unused IP from range 172.17.0.0/16
# Uses file lock as interprocess mutex
ip = None
with open('/var/lock/vmmgr-hosts.lock', 'w') as lock:
fcntl.lockf(lock, fcntl.LOCK_EX)
# Load all existing records
with open('/etc/hosts', 'r') as f:
leases = [l.strip().split(' ', 1) for l in f]
# If this call is a request for lease, find the first unassigned IP
if is_request:
used_ips = [l[0] for l in leases]
for i in range(2, 65534):
ip = '172.17.{}.{}'. format(i // 256, i % 256)
if ip not in used_ips:
leases.append([ip, app])
break
# Otherwise it is a release in which case we just delete the record
else:
leases = [l for l in leases if l[1] != app]
# Write the contents back to the file
with open('/etc/hosts', 'w') as f:
for lease in leases:
f.write('{} {}\n'.format(lease[0], lease[1]))
return ip
def configure_app(self, app):
# Supply common configuration for the application. Done as part of container preparation during service startup
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
with open(os.path.join(NGINX_DIR, '{}.conf'.format(app)), 'w') as f:
f.write(templates.NGINX.format(app=app, host=self.conf['packages'][app]['host'], domain=self.domain, port=self.port))
self.reload_nginx()
def unregister_proxy(self, app):
# Remove proxy configuration and reload nginx
os.unlink(os.path.join(NGINX_DIR, '{}.conf'.format(app)))
self.reload_nginx()

View File

@ -12,12 +12,12 @@ from jinja2 import Environment, FileSystemLoader
from cryptography.exceptions import InvalidSignature from cryptography.exceptions import InvalidSignature
from . import VMMgr, CERT_PUB_FILE
from . import tools from . import tools
from . import validator from . import validator
from .actionqueue import ActionQueue from .actionqueue import ActionQueue
from .appmgr import AppMgr from .appmgr import AppMgr
from .config import Config from .config import Config
from .vmmgr import VMMgr
from .wsgilang import WSGILang from .wsgilang import WSGILang
from .wsgisession import WSGISession from .wsgisession import WSGISession
@ -151,17 +151,19 @@ class WSGIApp(object):
ex_ipv6 = tools.get_external_ip(6) ex_ipv6 = tools.get_external_ip(6)
in_ipv4 = tools.get_local_ip(4) in_ipv4 = tools.get_local_ip(4)
in_ipv6 = tools.get_local_ip(6) in_ipv6 = tools.get_local_ip(6)
cert_info = tools.get_cert_info(CERT_PUB_FILE) cert_info = self.vmmgr.get_cert_info()
return self.render_html('setup-host.html', request, ex_ipv4=ex_ipv4, ex_ipv6=ex_ipv6, in_ipv4=in_ipv4, in_ipv6=in_ipv6, cert_info=cert_info) return self.render_html('setup-host.html', request, ex_ipv4=ex_ipv4, ex_ipv6=ex_ipv6, in_ipv4=in_ipv4, in_ipv6=in_ipv6, cert_info=cert_info)
def setup_apps_view(self, request): def setup_apps_view(self, request):
# Application manager view. # Application manager view.
repo_error = None repo_error = None
try: try:
self.appmgr.fetch_online_packages() status = self.appmgr.fetch_online_packages()
except InvalidSignature: except InvalidSignature:
repo_error = request.session.lang.invalild_packages_signature() repo_error = request.session.lang.invalid_packages_signature()
except: if status in (401, 403):
repo_error = request.session.lang.repo_invalid_credentials()
elif status != 200:
repo_error = request.session.lang.repo_unavailable() repo_error = request.session.lang.repo_unavailable()
table = self.render_setup_apps_table(request) table = self.render_setup_apps_table(request)
message = self.get_session_message(request) message = self.get_session_message(request)
@ -176,7 +178,7 @@ class WSGIApp(object):
installed = app in self.conf['apps'] installed = app in self.conf['apps']
title = self.conf['packages'][app]['title'] if installed else self.appmgr.online_packages[app]['title'] title = self.conf['packages'][app]['title'] if installed else self.appmgr.online_packages[app]['title']
visible = self.conf['apps'][app]['visible'] if installed else False visible = self.conf['apps'][app]['visible'] if installed else False
autostarted = tools.is_service_autostarted(app) if installed else False autostarted = self.appmgr.is_service_autostarted(app) if installed else False
if app in pending_actions: if app in pending_actions:
item = pending_actions[app] item = pending_actions[app]
actions = '<div class="loader"></div>' actions = '<div class="loader"></div>'
@ -221,7 +223,7 @@ class WSGIApp(object):
if not installed: if not installed:
status = lang.status_not_installed() status = lang.status_not_installed()
actions = '<a href="#" class="app-install">{}</a>'.format(lang.action_install()) actions = '<a href="#" class="app-install">{}</a>'.format(lang.action_install())
elif tools.is_service_started(app): elif self.appmgr.is_service_started(app):
status = '<span class="info">{}</span>'.format(lang.status_started()) status = '<span class="info">{}</span>'.format(lang.status_started())
actions = '<a href="#" class="app-stop">{}</a>'.format(lang.action_stop()) actions = '<a href="#" class="app-stop">{}</a>'.format(lang.action_stop())
else: else:
@ -234,14 +236,14 @@ class WSGIApp(object):
# Update domain and port, then restart nginx # Update domain and port, then restart nginx
domain = request.form['domain'] domain = request.form['domain']
port = request.form['port'] port = request.form['port']
if not validator.is_valid_domain(domain) if not validator.is_valid_domain(domain):
return self.render_json({'error': request.session.lang.invalid_domain(domain)}) return self.render_json({'error': request.session.lang.invalid_domain(domain)})
elif not validator.is_valid_port(port): elif not validator.is_valid_port(port):
return self.render_json({'error': request.session.lang.invalid_port(port)}) return self.render_json({'error': request.session.lang.invalid_port(port)})
self.vmmgr.update_host(domain, port) self.vmmgr.update_host(domain, port)
url = '{}/setup-host'.format(tools.compile_url(tools.get_local_ip(), port)) url = '{}/setup-host'.format(tools.compile_url(tools.get_local_ip(), port))
response = self.render_json({'ok': request.session.lang.host_updated(url, url)}) response = self.render_json({'ok': request.session.lang.host_updated(url, url)})
response.call_on_close(tools.restart_nginx) response.call_on_close(self.vmmgr.restart_nginx)
return response return response
def verify_dns_action(self, request): def verify_dns_action(self, request):
@ -376,14 +378,14 @@ class WSGIApp(object):
def reboot_vm_action(self, request): def reboot_vm_action(self, request):
# Reboots VM # Reboots VM
response = self.render_json({'ok': request.session.lang.reboot_initiated()}) response = self.render_json({'ok': request.session.lang.reboot_initiated()})
response.call_on_close(tools.reboot_vm) response.call_on_close(self.appmgr.reboot_vm)
return response return response
def shutdown_vm_action(self, request): def shutdown_vm_action(self, request):
# Shuts down VM # Shuts down VM
response = self.render_json({'ok': request.session.lang.shutdown_initiated()}) response = self.render_json({'ok': request.session.lang.shutdown_initiated()})
response.call_on_close(tools.shutdown_vm) response.call_on_close(self.appmgr.shutdown_vm)
return response return response
def is_app_visible(self, app): def is_app_visible(self, app):
return app in self.conf['apps'] and self.conf['apps'][app]['visible'] and tools.is_service_started(app) return app in self.conf['apps'] and self.conf['apps'][app]['visible'] and self.appmgr.is_service_started(app)

View File

@ -24,7 +24,8 @@ class WSGILang:
'installation_in_progress': 'Probíhá instalace jiného balíku. Vyčkejte na její dokončení.', 'installation_in_progress': 'Probíhá instalace jiného balíku. Vyčkejte na její dokončení.',
'package_manager_error': 'Došlo k chybě při instalaci aplikace. Zkuste akci opakovat nebo restartuje virtuální stroj.', 'package_manager_error': 'Došlo k chybě při instalaci aplikace. Zkuste akci opakovat nebo restartuje virtuální stroj.',
'invalid_packages_signature': 'Digitální podpis seznamu balíků není platný. Kontaktujte správce distribučního serveru.', 'invalid_packages_signature': 'Digitální podpis seznamu balíků není platný. Kontaktujte správce distribučního serveru.',
'repo_unavailable': 'Připojení k distribučnímu serveru se nezdařilo. Zkontrolujte přístupové údaje a připojení k síti.', 'repo_invalid_credentials': 'Přístupové údaje k distribučnímu serveru nejsou správné.',
'repo_unavailable': 'Distribuční server není dostupný. Zkontroluje připojení k síti',
'bad_password': 'Nesprávné heslo', 'bad_password': 'Nesprávné heslo',
'password_mismatch': 'Zadaná hesla se neshodují', 'password_mismatch': 'Zadaná hesla se neshodují',
'password_empty': 'Nové heslo nesmí být prázdné', 'password_empty': 'Nové heslo nesmí být prázdné',

View File

@ -1,8 +1,7 @@
#!/usr/bin/python3 #!/usr/bin/python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import sys from vmmgr import WSGIApp
from vmmgr.wsgiapp import WSGIApp
application = WSGIApp() application = WSGIApp()