Move tools into best fitting modules
This commit is contained in:
parent
75e86b0dcb
commit
c2b383e5c8
@ -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()
|
||||||
|
@ -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()
|
|
||||||
|
@ -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()
|
|
||||||
|
127
usr/lib/python3.6/vmmgr/templates.py
Normal file
127
usr/lib/python3.6/vmmgr/templates.py
Normal 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}"
|
||||||
|
'''
|
@ -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)
|
|
||||||
|
218
usr/lib/python3.6/vmmgr/vmmgr.py
Normal file
218
usr/lib/python3.6/vmmgr/vmmgr.py
Normal 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()
|
@ -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)
|
||||||
|
@ -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é',
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user