Move tools into best fitting modules
This commit is contained in:
parent
75e86b0dcb
commit
c2b383e5c8
@ -2,9 +2,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from vmmgr import VMMgr
|
||||
from vmmgr.config import Config
|
||||
from vmmgr import Config, VMMgr
|
||||
|
||||
parser = argparse.ArgumentParser(description='VM application manager')
|
||||
subparsers = parser.add_subparsers()
|
||||
|
@ -1,279 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
from . import tools
|
||||
from .appmgr import AppMgr
|
||||
from .config import Config
|
||||
from .vmmgr import VMMgr
|
||||
from .wsgiapp import WSGIApp
|
||||
|
||||
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 /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()
|
||||
__all__ = [
|
||||
'AppMgr',
|
||||
'Config',
|
||||
'VMMgr',
|
||||
'WSGIApp'
|
||||
]
|
||||
|
@ -40,7 +40,10 @@ class AppMgr:
|
||||
def fetch_online_packages(self):
|
||||
# Fetches and verifies online packages. Can raise InvalidSignature
|
||||
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
|
||||
with open(PUB_FILE, 'rb') as f:
|
||||
pub_key = load_pem_public_key(f.read(), default_backend())
|
||||
@ -48,23 +51,30 @@ class AppMgr:
|
||||
online_packages = json.loads(packages)
|
||||
# Minimze the time when self.online_packages is out of sync
|
||||
self.online_packages = online_packages
|
||||
return 200
|
||||
|
||||
def start_app(self, item):
|
||||
# Start the actual app service
|
||||
app = item.key
|
||||
if app in self.conf['apps'] and not tools.is_service_started(app):
|
||||
tools.start_service(app)
|
||||
if app in self.conf['apps'] and not self.is_service_started(app):
|
||||
self.start_service(app)
|
||||
|
||||
def start_service(self, service):
|
||||
subprocess.run(['/sbin/service', service, 'start'], check=True)
|
||||
|
||||
def stop_app(self, item):
|
||||
# Stop the actual app service
|
||||
app = item.key
|
||||
if app in self.conf['apps'] and tools.is_service_started(app):
|
||||
tools.stop_service(app)
|
||||
if app in self.conf['apps'] and self.is_service_started(app):
|
||||
self.stop_service(app)
|
||||
# Stop the app service's dependencies if they are not used by another running app
|
||||
deps = self.get_services_deps()
|
||||
for dep in tools.get_service_deps(app):
|
||||
if not any([tools.is_service_started(d) for d in deps[dep]]):
|
||||
tools.stop_service(dep)
|
||||
for dep in self.get_service_deps(app):
|
||||
if not any([self.is_service_started(d) for d in deps[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):
|
||||
# Update visibility for the app in the configuration
|
||||
@ -77,6 +87,14 @@ class AppMgr:
|
||||
if app in self.conf['apps']:
|
||||
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):
|
||||
# Main installation function. Wrapper for download, registration and install script
|
||||
app = item.key
|
||||
@ -102,7 +120,7 @@ class AppMgr:
|
||||
# Main uninstallation function. Wrapper for uninstall script, filesystem purge and unregistration
|
||||
app = item.key
|
||||
self.stop_app(item)
|
||||
if tools.is_service_autostarted(app):
|
||||
if self.is_service_autostarted(app):
|
||||
self.update_app_autostart(app, False)
|
||||
deps = self.get_install_deps(app, False)[::-1]
|
||||
for dep in deps:
|
||||
@ -119,12 +137,23 @@ class AppMgr:
|
||||
if chunk:
|
||||
installitem.downloaded += f.write(chunk)
|
||||
# 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)
|
||||
|
||||
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):
|
||||
# Unpack archive
|
||||
tmp_archive = '/tmp/{}.tar.xz'.format(name)
|
||||
print('Unpacking', ['tar', 'xJf', tmp_archive])
|
||||
subprocess.run(['tar', 'xJf', tmp_archive], cwd='/', check=True)
|
||||
os.unlink(tmp_archive)
|
||||
|
||||
@ -218,7 +247,7 @@ class AppMgr:
|
||||
# Fisrt, build a dictionary of {app: [needs]}
|
||||
needs = {}
|
||||
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]}
|
||||
deps = {}
|
||||
for app, need in needs.items():
|
||||
@ -226,6 +255,15 @@ class AppMgr:
|
||||
deps.setdefault(n, []).append(app)
|
||||
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):
|
||||
# Update common configuration values
|
||||
self.conf['common']['email'] = email
|
||||
@ -239,12 +277,8 @@ class AppMgr:
|
||||
self.conf['repo']['pwd'] = pwd
|
||||
self.conf.save()
|
||||
|
||||
def hash_file(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 shutdown_vm(self):
|
||||
subprocess.run(['/sbin/poweroff'])
|
||||
|
||||
def reboot_vm(self):
|
||||
subprocess.run(['/sbin/reboot'])
|
||||
|
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 dns.exception
|
||||
import dns.resolver
|
||||
import fcntl
|
||||
import os
|
||||
import requests
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.x509.oid import NameOID
|
||||
# Network tools
|
||||
|
||||
def compile_url(domain, port, proto='https'):
|
||||
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:
|
||||
return False
|
||||
|
||||
def get_service_deps(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 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
|
||||
# Admin password tools
|
||||
|
||||
def adminpwd_hash(password):
|
||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
def adminpwd_verify(password, hash):
|
||||
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 . import VMMgr, CERT_PUB_FILE
|
||||
from . import tools
|
||||
from . import validator
|
||||
from .actionqueue import ActionQueue
|
||||
from .appmgr import AppMgr
|
||||
from .config import Config
|
||||
from .vmmgr import VMMgr
|
||||
from .wsgilang import WSGILang
|
||||
from .wsgisession import WSGISession
|
||||
|
||||
@ -151,17 +151,19 @@ class WSGIApp(object):
|
||||
ex_ipv6 = tools.get_external_ip(6)
|
||||
in_ipv4 = tools.get_local_ip(4)
|
||||
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)
|
||||
|
||||
def setup_apps_view(self, request):
|
||||
# Application manager view.
|
||||
repo_error = None
|
||||
try:
|
||||
self.appmgr.fetch_online_packages()
|
||||
status = self.appmgr.fetch_online_packages()
|
||||
except InvalidSignature:
|
||||
repo_error = request.session.lang.invalild_packages_signature()
|
||||
except:
|
||||
repo_error = request.session.lang.invalid_packages_signature()
|
||||
if status in (401, 403):
|
||||
repo_error = request.session.lang.repo_invalid_credentials()
|
||||
elif status != 200:
|
||||
repo_error = request.session.lang.repo_unavailable()
|
||||
table = self.render_setup_apps_table(request)
|
||||
message = self.get_session_message(request)
|
||||
@ -176,7 +178,7 @@ class WSGIApp(object):
|
||||
installed = app in self.conf['apps']
|
||||
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
|
||||
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:
|
||||
item = pending_actions[app]
|
||||
actions = '<div class="loader"></div>'
|
||||
@ -221,7 +223,7 @@ class WSGIApp(object):
|
||||
if not installed:
|
||||
status = lang.status_not_installed()
|
||||
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())
|
||||
actions = '<a href="#" class="app-stop">{}</a>'.format(lang.action_stop())
|
||||
else:
|
||||
@ -234,14 +236,14 @@ class WSGIApp(object):
|
||||
# Update domain and port, then restart nginx
|
||||
domain = request.form['domain']
|
||||
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)})
|
||||
elif not validator.is_valid_port(port):
|
||||
return self.render_json({'error': request.session.lang.invalid_port(port)})
|
||||
self.vmmgr.update_host(domain, 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.call_on_close(tools.restart_nginx)
|
||||
response.call_on_close(self.vmmgr.restart_nginx)
|
||||
return response
|
||||
|
||||
def verify_dns_action(self, request):
|
||||
@ -376,14 +378,14 @@ class WSGIApp(object):
|
||||
def reboot_vm_action(self, request):
|
||||
# Reboots VM
|
||||
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
|
||||
|
||||
def shutdown_vm_action(self, request):
|
||||
# Shuts down VM
|
||||
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
|
||||
|
||||
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í.',
|
||||
'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.',
|
||||
'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',
|
||||
'password_mismatch': 'Zadaná hesla se neshodují',
|
||||
'password_empty': 'Nové heslo nesmí být prázdné',
|
||||
|
@ -1,8 +1,7 @@
|
||||
#!/usr/bin/python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import sys
|
||||
from vmmgr.wsgiapp import WSGIApp
|
||||
from vmmgr import WSGIApp
|
||||
|
||||
application = WSGIApp()
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user