400 lines
19 KiB
Python
400 lines
19 KiB
Python
|
# -*- coding: utf-8 -*-
|
||
|
|
||
|
import json
|
||
|
import os
|
||
|
|
||
|
from werkzeug.exceptions import BadRequest, HTTPException, NotFound
|
||
|
from werkzeug.routing import Map, Rule
|
||
|
from werkzeug.utils import redirect
|
||
|
from werkzeug.wrappers import Request, Response
|
||
|
from werkzeug.wsgi import ClosingIterator
|
||
|
from jinja2 import Environment, FileSystemLoader
|
||
|
|
||
|
from . import VMMgr, CERT_PUB_FILE
|
||
|
from . import tools
|
||
|
from .appmgr import AppMgr
|
||
|
from .validator import InvalidValueException
|
||
|
from .wsgilang import WSGILang
|
||
|
from .wsgisession import WSGISession
|
||
|
|
||
|
SESSION_KEY = os.urandom(26)
|
||
|
|
||
|
class WSGIApp(object):
|
||
|
def __init__(self):
|
||
|
self.vmmgr = VMMgr()
|
||
|
self.appmgr = AppMgr(self.vmmgr)
|
||
|
self.conf = self.vmmgr.conf
|
||
|
self.jinja_env = Environment(loader=FileSystemLoader('/srv/vm/templates'), autoescape=True, lstrip_blocks=True, trim_blocks=True)
|
||
|
self.jinja_env.globals.update(is_app_visible=self.is_app_visible)
|
||
|
self.jinja_env.globals.update(is_service_autostarted=tools.is_service_autostarted)
|
||
|
self.jinja_env.globals.update(is_service_started=tools.is_service_started)
|
||
|
|
||
|
def __call__(self, environ, start_response):
|
||
|
return self.wsgi_app(environ, start_response)
|
||
|
|
||
|
def wsgi_app(self, environ, start_response):
|
||
|
request = Request(environ)
|
||
|
# Reload config in case it has changed between requests
|
||
|
self.conf.load()
|
||
|
# Enhance request
|
||
|
request.session = WSGISession(request.cookies, SESSION_KEY)
|
||
|
request.session.lang = WSGILang()
|
||
|
# Dispatch request
|
||
|
response = self.dispatch_request(request)
|
||
|
# Save session if changed
|
||
|
request.session.save(response)
|
||
|
return response(environ, start_response)
|
||
|
|
||
|
def dispatch_request(self, request):
|
||
|
adapter = self.get_url_map(request.session).bind_to_environ(request.environ)
|
||
|
try:
|
||
|
endpoint, values = adapter.match()
|
||
|
return getattr(self, endpoint)(request, **values)
|
||
|
except NotFound as e:
|
||
|
# Return custom 404 page
|
||
|
response = self.render_template('404.html', request)
|
||
|
response.status_code = 404
|
||
|
return response
|
||
|
except HTTPException as e:
|
||
|
return e
|
||
|
|
||
|
def get_url_map(self, session):
|
||
|
rules = [
|
||
|
Rule('/', endpoint='portal_view'),
|
||
|
Rule('/login', methods=['GET'], endpoint='login_view', defaults={'redirect': '/'}),
|
||
|
Rule('/login', methods=['POST'], endpoint='login_action'),
|
||
|
Rule('/logout', endpoint='logout_action')
|
||
|
]
|
||
|
if session['admin']:
|
||
|
rules += [
|
||
|
Rule('/setup-host', endpoint='setup_host_view'),
|
||
|
Rule('/setup-apps', endpoint='setup_apps_view'),
|
||
|
Rule('/update-host', endpoint='update_host_action'),
|
||
|
Rule('/verify-dns', endpoint='verify_dns_action'),
|
||
|
Rule('/verify-https', endpoint='verify_http_action', defaults={'proto': 'https'}),
|
||
|
Rule('/verify-http', endpoint='verify_http_action', defaults={'proto': 'http'}),
|
||
|
Rule('/update-cert', endpoint='update_cert_action'),
|
||
|
Rule('/update-common', endpoint='update_common_action'),
|
||
|
Rule('/update-repo', endpoint='update_repo_action'),
|
||
|
Rule('/update-app-visibility', endpoint='update_app_visibility_action'),
|
||
|
Rule('/update-app-autostart', endpoint='update_app_autostart_action'),
|
||
|
Rule('/start-app', endpoint='start_app_action'),
|
||
|
Rule('/stop-app', endpoint='stop_app_action'),
|
||
|
Rule('/install-app', endpoint='install_app_action'),
|
||
|
Rule('/get-progress', endpoint='get_progress_action'),
|
||
|
Rule('/uninstall-app', endpoint='uninstall_app_action'),
|
||
|
Rule('/update-password', endpoint='update_password_action'),
|
||
|
Rule('/shutdown-vm', endpoint='shutdown_vm_action'),
|
||
|
Rule('/reboot-vm', endpoint='reboot_vm_action'),
|
||
|
]
|
||
|
else:
|
||
|
rules += [
|
||
|
Rule('/setup-host', endpoint='login_view', defaults={'redirect': '/setup-host'}),
|
||
|
Rule('/setup-apps', endpoint='login_view', defaults={'redirect': '/setup-apps'}),
|
||
|
]
|
||
|
return Map(rules)
|
||
|
|
||
|
def render_template(self, template_name, request, **context):
|
||
|
# Enhance context
|
||
|
context['conf'] = self.conf
|
||
|
context['session'] = request.session
|
||
|
# Render template
|
||
|
t = self.jinja_env.get_template(template_name)
|
||
|
return Response(t.render(context), mimetype='text/html')
|
||
|
|
||
|
def render_json(self, data):
|
||
|
return Response(json.dumps(data), mimetype='application/json')
|
||
|
|
||
|
def login_view(self, request, **kwargs):
|
||
|
return self.render_template('login.html', request, redirect=kwargs['redirect'])
|
||
|
|
||
|
def login_action(self, request):
|
||
|
password = request.form['password']
|
||
|
redir_url = request.form['redirect']
|
||
|
if tools.adminpwd_verify(password, self.conf['host']['adminpwd']):
|
||
|
request.session['admin'] = True
|
||
|
return redirect(redir_url)
|
||
|
else:
|
||
|
return self.render_template('login.html', request, message=request.session.lang.bad_password())
|
||
|
|
||
|
def logout_action(self, request):
|
||
|
request.session.reset()
|
||
|
return redirect('/')
|
||
|
|
||
|
def portal_view(self, request):
|
||
|
# Default portal view. If this is the first run, perform first-run setup.
|
||
|
if self.conf['host']['firstrun']:
|
||
|
# Set user as admin
|
||
|
request.session['admin'] = True
|
||
|
# Disable and save first-run flag
|
||
|
self.conf['host']['firstrun'] = False
|
||
|
self.conf.save()
|
||
|
# Redirect to host setup view
|
||
|
return redirect('/setup-host')
|
||
|
host = tools.compile_url(self.conf['host']['domain'], self.conf['host']['port'])[8:]
|
||
|
if request.session['admin']:
|
||
|
return self.render_template('portal-admin.html', request, host=host)
|
||
|
return self.render_template('portal-user.html', request, host=host)
|
||
|
|
||
|
def setup_host_view(self, request):
|
||
|
# Host setup view.
|
||
|
ex_ipv4 = tools.get_external_ipv4()
|
||
|
ex_ipv6 = tools.get_external_ipv6()
|
||
|
in_ipv4 = tools.get_local_ipv4()
|
||
|
in_ipv6 = tools.get_local_ipv6()
|
||
|
cert_info = tools.get_cert_info(CERT_PUB_FILE)
|
||
|
return self.render_template('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.
|
||
|
try:
|
||
|
self.appmgr.fetch_online_packages()
|
||
|
except:
|
||
|
pass
|
||
|
all_apps = sorted(set([k for k,v in self.appmgr.online_packages.items() if 'host' in v] + list(self.conf['apps'].keys())))
|
||
|
return self.render_template('setup-apps.html', request, all_apps=all_apps, online_packages=self.appmgr.online_packages)
|
||
|
|
||
|
def render_setup_apps_row(self, request, app, app_title, item):
|
||
|
lang = request.session.lang
|
||
|
actions = '<div class="loader"></div>'
|
||
|
if item.action == 'start_app':
|
||
|
if not item.started:
|
||
|
status = 'Spouští se (ve frontě)'
|
||
|
elif not item.finished:
|
||
|
status = 'Spouští se'
|
||
|
elif isinstance(item.data, BaseException):
|
||
|
status = '<span class="error">{}</span>'.format(lang.stop_start_error())
|
||
|
else:
|
||
|
status = '<span class="info">Spuštěna</span>'
|
||
|
actions = '<a href="#" class="app-stop">Zastavit</a>'
|
||
|
elif item.action == 'stop_app':
|
||
|
if not item.started:
|
||
|
status = 'Zastavuje se (ve frontě)'
|
||
|
elif not item.finished:
|
||
|
status = 'Zastavuje se'
|
||
|
elif isinstance(item.data, BaseException):
|
||
|
status = '<span class="error">{}</span>'.format(lang.stop_start_error())
|
||
|
else:
|
||
|
status = '<span class="error">Zastavena</span>'
|
||
|
actions = '<a href="#" class="app-start">Spustit</a>, <a href="#" class="app-uninstall">Odinstalovat</a>'
|
||
|
elif item.action == 'install_app':
|
||
|
if not item.started:
|
||
|
status = 'Stahuje se (ve frontě)'
|
||
|
elif not item.finished:
|
||
|
if item.data.stage == 0:
|
||
|
status = 'Stahuje se ({} %)'.format(item.data)
|
||
|
elif item.data.stage == 1:
|
||
|
status = 'Instalují se závislosti'
|
||
|
else:
|
||
|
status = 'Instaluje se'
|
||
|
elif isinstance(item.data, BaseException):
|
||
|
status = '<span class="error">{}</span>'.format(lang.package_manager_error())
|
||
|
else:
|
||
|
status = '<span class="error">Zastavena</span>'
|
||
|
actions = '<a href="#" class="app-start">Spustit</a>, <a href="#" class="app-uninstall">Odinstalovat</a>'
|
||
|
elif item.action == 'uninstall_app':
|
||
|
if not item.started:
|
||
|
status = 'Odinstalovává se (ve frontě)'
|
||
|
elif not item.finished:
|
||
|
status = 'Odinstalovává se'
|
||
|
elif isinstance(item.data, BaseException):
|
||
|
status = '<span class="error">{}</span>'.format(lang.package_manager_error())
|
||
|
else:
|
||
|
status = 'Není nainstalována'
|
||
|
actions = '<a href="#" class="app-install">Instalovat</a>'
|
||
|
is_error = isinstance(item.data, BaseException)
|
||
|
t = self.jinja_env.get_template('setup-apps-row.html')
|
||
|
return t.render({'conf': self.conf, 'session': request.session, 'app': app, 'app_title': app_title, 'status': status, 'actions': actions, 'is_error': is_error})
|
||
|
|
||
|
def update_host_action(self, request):
|
||
|
# Update domain and port, then restart nginx
|
||
|
try:
|
||
|
domain = request.form['domain']
|
||
|
port = request.form['port']
|
||
|
self.vmmgr.update_host(domain, port)
|
||
|
server_name = request.environ['HTTP_X_FORWARDED_SERVER_NAME']
|
||
|
url = '{}/setup-host'.format(tools.compile_url(server_name, port))
|
||
|
response = self.render_json({'ok': request.session.lang.host_updated(url, url)})
|
||
|
response.call_on_close(tools.restart_nginx)
|
||
|
return response
|
||
|
except BadRequest:
|
||
|
return self.render_json({'error': request.session.lang.malformed_request()})
|
||
|
except InvalidValueException as e:
|
||
|
if e.args[0] == 'domain':
|
||
|
return self.render_json({'error': request.session.lang.invalid_domain(domain)})
|
||
|
if e.args[0] == 'port':
|
||
|
return self.render_json({'error': request.session.lang.invalid_port(port)})
|
||
|
|
||
|
def verify_dns_action(self, request):
|
||
|
# Check if all FQDNs for all applications are resolvable and point to current external IP
|
||
|
domains = [self.vmmgr.domain]+['{}.{}'.format(self.conf['apps'][app]['host'], self.vmmgr.domain) for app in self.conf['apps']]
|
||
|
ipv4 = tools.get_external_ipv4()
|
||
|
ipv6 = tools.get_external_ipv6()
|
||
|
for domain in domains:
|
||
|
try:
|
||
|
a = tools.resolve_ip(domain, 'A')
|
||
|
aaaa = tools.resolve_ip(domain, 'AAAA')
|
||
|
if not a and not aaaa:
|
||
|
return self.render_json({'error': request.session.lang.dns_record_does_not_exist(domain)})
|
||
|
if a and a != ipv4:
|
||
|
return self.render_json({'error': request.session.lang.dns_record_mismatch(domain, a, ipv4)})
|
||
|
if aaaa and aaaa != ipv6:
|
||
|
return self.render_json({'error': request.session.lang.dns_record_mismatch(domain, aaaa, ipv6)})
|
||
|
except:
|
||
|
return self.render_json({'error': request.session.lang.dns_timeout()})
|
||
|
return self.render_json({'ok': request.session.lang.dns_records_ok()})
|
||
|
|
||
|
def verify_http_action(self, request, **kwargs):
|
||
|
# Check if all applications are accessible from the internet using 3rd party ping service
|
||
|
proto = kwargs['proto']
|
||
|
port = self.vmmgr.port if proto == 'https' else '80'
|
||
|
domains = [self.vmmgr.domain]+['{}.{}'.format(self.conf['apps'][app]['host'], self.vmmgr.domain) for app in self.conf['apps']]
|
||
|
for domain in domains:
|
||
|
url = tools.compile_url(domain, port, proto)
|
||
|
try:
|
||
|
if not tools.ping_url(url):
|
||
|
return self.render_json({'error': request.session.lang.http_host_not_reachable(url)})
|
||
|
except:
|
||
|
return self.render_json({'error': request.session.lang.http_timeout()})
|
||
|
return self.render_json({'ok': request.session.lang.http_hosts_ok(port)})
|
||
|
|
||
|
def update_cert_action(self, request):
|
||
|
# Update certificate - either request via Let's Encrypt or manually upload files
|
||
|
try:
|
||
|
if request.form['method'] not in ['selfsigned', 'automatic', 'manual']:
|
||
|
raise BadRequest()
|
||
|
if request.form['method'] == 'selfsigned':
|
||
|
self.vmmgr.create_selfsigned_cert()
|
||
|
elif request.form['method'] == 'automatic':
|
||
|
self.vmmgr.request_acme_cert()
|
||
|
else:
|
||
|
if not request.files['public']:
|
||
|
return self.render_json({'error': request.session.lang.cert_file_missing()})
|
||
|
if not request.files['private']:
|
||
|
return self.render_json({'error': request.session.lang.key_file_missing()})
|
||
|
request.files['public'].save('/tmp/public.pem')
|
||
|
request.files['private'].save('/tmp/private.pem')
|
||
|
self.vmmgr.install_manual_cert('/tmp/public.pem', '/tmp/private.pem')
|
||
|
os.unlink('/tmp/public.pem')
|
||
|
os.unlink('/tmp/private.pem')
|
||
|
except BadRequest:
|
||
|
return self.render_json({'error': request.session.lang.malformed_request()})
|
||
|
except:
|
||
|
return self.render_json({'error': request.session.lang.cert_request_error()})
|
||
|
url = tools.compile_url(self.vmmgr.domain, self.vmmgr.port)
|
||
|
return self.render_json({'ok': request.session.lang.cert_installed(url, url)})
|
||
|
|
||
|
def update_common_action(self, request):
|
||
|
# Update common settings shared between apps - admin e-mail address, Google Maps API key
|
||
|
try:
|
||
|
self.vmmgr.update_common(request.form['email'], request.form['gmaps-api-key'])
|
||
|
except BadRequest:
|
||
|
return self.render_json({'error': request.session.lang.malformed_request()})
|
||
|
return self.render_json({'ok': request.session.lang.common_updated()})
|
||
|
|
||
|
def update_repo_action(self, request):
|
||
|
# Update repository URL and credentials
|
||
|
try:
|
||
|
self.conf['repo']['url'] = request.form['repourl']
|
||
|
self.conf['repo']['user'] = request.form['repousername']
|
||
|
self.conf['repo']['pwd'] = request.form['repopassword']
|
||
|
self.conf.save()
|
||
|
except:
|
||
|
pass
|
||
|
return redirect('/setup-apps')
|
||
|
|
||
|
def update_app_visibility_action(self, request):
|
||
|
# Update application visibility on portal page
|
||
|
try:
|
||
|
if request.form['value'] == 'true':
|
||
|
self.vmmgr.show_tiles(request.form['app'])
|
||
|
else:
|
||
|
self.vmmgr.hide_tiles(request.form['app'])
|
||
|
except (BadRequest, InvalidValueException):
|
||
|
return self.render_json({'error': request.session.lang.malformed_request()})
|
||
|
return self.render_json({'ok': 'ok'})
|
||
|
|
||
|
def update_app_autostart_action(self, request):
|
||
|
# Update value determining if the app should be automatically started after VM boot
|
||
|
try:
|
||
|
if request.form['value'] == 'true':
|
||
|
self.vmmgr.enable_autostart(request.form['app'])
|
||
|
else:
|
||
|
self.vmmgr.disable_autostart(request.form['app'])
|
||
|
except (BadRequest, InvalidValueException):
|
||
|
return self.render_json({'error': request.session.lang.malformed_request()})
|
||
|
return self.render_json({'ok': 'ok'})
|
||
|
|
||
|
def enqueue_action(self, request, action):
|
||
|
try:
|
||
|
app = request.form['app']
|
||
|
except BadRequest:
|
||
|
return self.render_json({'error': request.session.lang.malformed_request()})
|
||
|
app_title = self.conf['apps'][app]['title'] if app in self.conf['apps'] else self.appmgr.online_packages[app]['title']
|
||
|
id,item = self.appmgr.enqueue_action(action, app)
|
||
|
response = self.render_json({'html': self.render_setup_apps_row(request, app, app_title, item), 'id': id})
|
||
|
response.call_on_close(lambda: self.appmgr.process_action(id))
|
||
|
return response
|
||
|
|
||
|
def start_app_action(self, request):
|
||
|
# Queues application start along with its dependencies
|
||
|
return self.enqueue_action(request, 'start_app')
|
||
|
|
||
|
def stop_app_action(self, request):
|
||
|
# Queues application stop along with its dependencies
|
||
|
return self.enqueue_action(request, 'stop_app')
|
||
|
|
||
|
def install_app_action(self, request):
|
||
|
# Queues application installation
|
||
|
return self.enqueue_action(request, 'install_app')
|
||
|
|
||
|
def uninstall_app_action(self, request):
|
||
|
# Queues application uninstallation
|
||
|
return self.enqueue_action(request, 'uninstall_app')
|
||
|
|
||
|
def get_progress_action(self, request):
|
||
|
# Gets appmgr queue status for given ids
|
||
|
json = {}
|
||
|
try:
|
||
|
ids = request.form.getlist('ids[]')
|
||
|
except BadRequest:
|
||
|
return self.render_json({'error': request.session.lang.malformed_request()})
|
||
|
actions = self.appmgr.get_actions(ids)
|
||
|
for id,item in actions.items():
|
||
|
app = item.app
|
||
|
# In case of installation error, we need to get the name from online_packages as the app is not yet registered
|
||
|
app_title = self.conf['apps'][app]['title'] if app in self.conf['apps'] else self.appmgr.online_packages[app]['title']
|
||
|
json[id] = {'html': self.render_setup_apps_row(request, app, app_title, item), 'last': item.finished}
|
||
|
return self.render_json(json)
|
||
|
|
||
|
def update_password_action(self, request):
|
||
|
# Updates password for both HDD encryption (LUKS-on-LVM) and web interface admin account
|
||
|
try:
|
||
|
if request.form['newpassword'] != request.form['newpassword2']:
|
||
|
return self.render_json({'error': request.session.lang.password_mismatch()})
|
||
|
if request.form['newpassword'] == '':
|
||
|
return self.render_json({'error': request.session.lang.password_empty()})
|
||
|
# No need to explicitly validate old password, update_luks_password will raise exception if it's wrong
|
||
|
self.vmmgr.update_password(request.form['oldpassword'], request.form['newpassword'])
|
||
|
except:
|
||
|
return self.render_json({'error': request.session.lang.bad_password()})
|
||
|
return self.render_json({'ok': request.session.lang.password_changed()})
|
||
|
|
||
|
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)
|
||
|
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)
|
||
|
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)
|
||
|
|
||
|
class InvalidRecordException(Exception):
|
||
|
pass
|