Spotter-VM/basic/srv/vm/mgr/wsgiapp.py

401 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'], None)
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 = 'Rozbaluje se'
elif item.data.stage == 2:
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>'
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})
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