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

354 lines
17 KiB
Python
Raw Normal View History

2018-08-02 10:41:40 +02:00
# -*- coding: utf-8 -*-
import json
import os
from werkzeug.exceptions import BadRequest, HTTPException, NotFound
2018-08-02 10:41:40 +02:00
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
2018-09-04 21:42:26 +02:00
from . import VMMgr, CERT_PUB_FILE
2018-08-02 10:41:40 +02:00
from . import tools
2018-10-21 11:36:32 +02:00
from .pkgmgr import PackageManager
2018-08-02 10:41:40 +02:00
from .validator import InvalidValueException
from .wsgilang import WSGILang
from .wsgisession import WSGISession
2018-08-02 10:41:40 +02:00
SESSION_KEY = os.urandom(26)
2018-08-02 10:41:40 +02:00
class WSGIApp(object):
def __init__(self):
self.vmmgr = VMMgr()
2018-10-26 17:31:40 +02:00
self.conf = self.vmmgr.conf
self.pkgmgr = PackageManager(self.conf)
2018-09-03 17:24:48 +02:00
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)
2018-08-02 10:41:40 +02:00
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)
2018-10-26 17:31:40 +02:00
# 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
2018-08-02 10:41:40 +02:00
response = self.dispatch_request(request)
# Save session if changed
request.session.save(response)
return response(environ, start_response)
2018-08-02 10:41:40 +02:00
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'),
Rule('/login', methods=['POST'], endpoint='login_action'),
Rule('/logout', endpoint='logout_action')
]
if session['admin']:
rules += [
2018-08-02 10:41:40 +02:00
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-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'),
2018-10-26 17:31:40 +02:00
Rule('/get-install-progress', endpoint='get_install_progress_action'),
2018-10-26 14:12:12 +02:00
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'),
]
return Map(rules)
2018-08-02 10:41:40 +02:00
def render_template(self, template_name, request, **context):
# Enhance context
2018-10-26 17:31:40 +02:00
context['conf'] = self.conf
context['session'] = request.session
# Render template
2018-08-02 10:41:40 +02:00
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):
return self.render_template('login.html', request)
def login_action(self, request):
password = request.form['password']
2018-10-26 17:31:40 +02:00
if tools.adminpwd_verify(password, self.conf['host']['adminpwd']):
request.session['admin'] = True
return redirect('/')
else:
return self.render_template('login.html', request, message=request.session.lang.bad_password())
def logout_action(self, request):
request.session.reset()
return redirect('/')
2018-08-02 10:41:40 +02:00
def portal_view(self, request):
# Default portal view. If this is the first run, perform first-run setup.
2018-10-26 17:31:40 +02:00
if self.conf['host']['firstrun']:
# Set user as admin
request.session['admin'] = True
# Disable and save first-run flag
2018-10-26 17:31:40 +02:00
self.conf['host']['firstrun'] = False
self.conf.save()
# Redirect to host setup view
return redirect('/setup-host')
2018-10-26 17:31:40 +02:00
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)
2018-08-02 10:41:40 +02:00
def setup_host_view(self, request):
# Host setup view.
2018-08-02 10:41:40 +02:00
ex_ipv4 = tools.get_external_ipv4()
ex_ipv6 = tools.get_external_ipv6()
in_ipv4 = tools.get_local_ipv4()
in_ipv6 = tools.get_local_ipv6()
is_letsencrypt = os.path.exists('/etc/periodic/daily/acme-sh')
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, is_letsencrypt=is_letsencrypt, cert_info=cert_info)
2018-08-02 10:41:40 +02:00
def setup_apps_view(self, request):
# Application manager view.
try:
self.pkgmgr.fetch_online_packages()
except:
pass
2018-10-26 17:31:40 +02:00
all_apps = sorted(set([k for k,v in self.pkgmgr.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.pkgmgr.online_packages)
2018-08-02 10:41:40 +02:00
def render_setup_apps_row(self, app, app_title, total_size=None):
t = self.jinja_env.get_template('setup-apps-row.html')
return t.render({'app': app, 'app_title': app_title, 'conf': self.conf, 'total_size': total_size})
2018-08-02 10:41:40 +02:00
def update_host_action(self, request):
# Update domain and port, then restart nginx
2018-08-02 10:41:40 +02:00
try:
domain = request.form['domain']
port = request.form['port']
self.vmmgr.update_host(domain, port, False)
2018-08-02 10:41:40 +02:00
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
2018-08-02 10:41:40 +02:00
except BadRequest:
return self.render_json({'error': request.session.lang.malformed_request()})
2018-08-02 10:41:40 +02:00
except InvalidValueException as e:
if e.args[0] == 'domain':
return self.render_json({'error': request.session.lang.invalid_domain(domain)})
2018-08-02 10:41:40 +02:00
if e.args[0] == 'port':
return self.render_json({'error': request.session.lang.invalid_port(port)})
2018-08-02 10:41:40 +02:00
def verify_dns_action(self, request):
# Check if all FQDNs for all applications are resolvable and point to current external IP
2018-10-26 17:31:40 +02:00
domains = [self.vmmgr.domain]+['{}.{}'.format(self.conf['apps'][app]['host'], self.vmmgr.domain) for app in self.conf['apps']]
2018-08-02 10:41:40 +02:00
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)})
2018-08-02 10:41:40 +02:00
if a and a != ipv4:
return self.render_json({'error': request.session.lang.dns_record_mismatch(domain, a, ipv4)})
2018-08-02 10:41:40 +02:00
if aaaa and aaaa != ipv6:
return self.render_json({'error': request.session.lang.dns_record_mismatch(domain, aaaa, ipv6)})
2018-08-02 10:41:40 +02:00
except:
return self.render_json({'error': request.session.lang.dns_timeout()})
return self.render_json({'ok': request.session.lang.dns_records_ok()})
2018-08-02 10:41:40 +02:00
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'
2018-10-26 17:31:40 +02:00
domains = [self.vmmgr.domain]+['{}.{}'.format(self.conf['apps'][app]['host'], self.vmmgr.domain) for app in self.conf['apps']]
2018-08-02 10:41:40 +02:00
for domain in domains:
url = tools.compile_url(domain, port, proto)
2018-08-02 10:41:40 +02:00
try:
if not tools.ping_url(url):
return self.render_json({'error': request.session.lang.http_host_not_reachable(url)})
2018-08-02 10:41:40 +02:00
except:
return self.render_json({'error': request.session.lang.http_timeout()})
return self.render_json({'ok': request.session.lang.http_hosts_ok(port)})
2018-08-02 10:41:40 +02:00
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 ['auto', 'manual']:
raise BadRequest()
if request.form['method'] == 'manual':
if not request.files['public']:
return self.render_json({'error': request.session.lang.cert_file_missing()})
2018-08-02 10:41:40 +02:00
if not request.files['private']:
return self.render_json({'error': request.session.lang.key_file_missing()})
2018-08-02 10:41:40 +02:00
request.files['public'].save('/tmp/public.pem')
request.files['private'].save('/tmp/private.pem')
self.vmmgr.install_cert('/tmp/public.pem', '/tmp/private.pem')
2018-08-02 10:41:40 +02:00
os.unlink('/tmp/public.pem')
os.unlink('/tmp/private.pem')
else:
self.vmmgr.request_cert()
2018-08-02 10:41:40 +02:00
except BadRequest:
return self.render_json({'error': request.session.lang.malformed_request()})
2018-08-02 10:41:40 +02:00
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)})
2018-08-02 10:41:40 +02:00
def update_common_action(self, request):
# Update common settings shared between apps - admin e-mail address, Google Maps API key
2018-08-02 10:41:40 +02:00
try:
self.vmmgr.update_common(request.form['email'], request.form['gmaps-api-key'])
2018-08-02 10:41:40 +02:00
except BadRequest:
return self.render_json({'error': request.session.lang.malformed_request()})
return self.render_json({'ok': request.session.lang.common_updated()})
2018-08-02 10:41:40 +02:00
def update_app_visibility_action(self, request):
# Update application visibility on portal page
2018-08-02 10:41:40 +02:00
try:
if request.form['value'] == 'true':
self.vmmgr.show_tiles(request.form['app'])
2018-08-02 10:41:40 +02:00
else:
self.vmmgr.hide_tiles(request.form['app'])
2018-08-02 10:41:40 +02:00
except (BadRequest, InvalidValueException):
return self.render_json({'error': request.session.lang.malformed_request()})
2018-08-02 10:41:40 +02:00
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
2018-08-02 10:41:40 +02:00
try:
if request.form['value'] == 'true':
self.vmmgr.enable_autostart(request.form['app'])
2018-08-02 10:41:40 +02:00
else:
self.vmmgr.disable_autostart(request.form['app'])
2018-08-02 10:41:40 +02:00
except (BadRequest, InvalidValueException):
return self.render_json({'error': request.session.lang.malformed_request()})
2018-08-02 10:41:40 +02:00
return self.render_json({'ok': 'ok'})
def start_app_action(self, request):
# Starts application along with its dependencies
2018-08-02 10:41:40 +02:00
try:
app = request.form['app']
self.vmmgr.start_app(app)
2018-08-02 10:41:40 +02:00
except (BadRequest, InvalidValueException):
return self.render_json({'error': request.session.lang.malformed_request()})
2018-08-02 10:41:40 +02:00
except:
return self.render_json({'error': request.session.lang.stop_start_error()})
2018-10-26 17:31:40 +02:00
app_title = self.conf['apps'][app]['title']
return self.render_json({'ok': self.render_setup_apps_row(app, app_title)})
2018-08-02 10:41:40 +02:00
def stop_app_action(self, request):
# Stops application along with its dependencies
2018-08-02 10:41:40 +02:00
try:
app = request.form['app']
2018-10-26 21:52:06 +02:00
if tools.is_service_started(app):
self.vmmgr.stop_app(app)
2018-08-02 10:41:40 +02:00
except (BadRequest, InvalidValueException):
return self.render_json({'error': request.session.lang.malformed_request()})
2018-08-02 10:41:40 +02:00
except:
return self.render_json({'error': request.session.lang.stop_start_error()})
2018-10-26 17:31:40 +02:00
app_title = self.conf['apps'][app]['title']
return self.render_json({'ok': self.render_setup_apps_row(app, app_title)})
def install_app_action(self, request):
2018-10-26 17:31:40 +02:00
# Registers the application installation as pending.
if self.pkgmgr.pending:
return self.render_json({'error': request.session.lang.installation_in_progress()})
try:
app = request.form['app']
total_size = self.pkgmgr.register_pending_installation(app)
except (BadRequest, InvalidValueException):
return self.render_json({'error': request.session.lang.malformed_request()})
except:
return self.render_json({'error': request.session.lang.package_manager_error()})
2018-10-26 17:31:40 +02:00
app_title = self.pkgmgr.online_packages[app]['title']
2018-10-26 22:11:34 +02:00
response = self.render_json({'ok': self.render_setup_apps_row(app, app_title, '{:.1f}'.format(total_size / 1048576))})
response.call_on_close(lambda: self.pkgmgr.install_package(app))
2018-10-26 17:31:40 +02:00
return response
def get_install_progress_action(self, request):
2018-10-26 21:52:06 +02:00
# Gets pending installation status
2018-10-26 17:31:40 +02:00
if self.pkgmgr.pending:
2018-10-26 22:11:34 +02:00
return self.render_json({'progress': '{:.1f}'.format(self.pkgmgr.pending / 1048576)})
2018-10-26 17:31:40 +02:00
app = request.form['app']
app_title = self.conf['apps'][app]['title']
return self.render_json({'ok': self.render_setup_apps_row(app, app_title)})
2018-10-26 14:12:12 +02:00
def uninstall_app_action(self, request):
# Uninstalls application
try:
app = request.form['app']
2018-10-26 21:52:06 +02:00
app_title = self.conf['apps'][app]['title']
2018-10-26 14:12:12 +02:00
self.vmmgr.stop_app(app)
2018-10-26 21:52:06 +02:00
self.pkgmgr.uninstall_package(app)
2018-10-26 14:12:12 +02:00
except (BadRequest, InvalidValueException):
return self.render_json({'error': request.session.lang.malformed_request()})
except:
2018-10-26 21:52:06 +02:00
raise
# return self.render_json({'error': request.session.lang.package_manager_error()})
2018-10-26 14:12:12 +02:00
return self.render_json({'ok': self.render_setup_apps_row(app, app_title)})
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
2018-08-02 10:41:40 +02:00
def is_app_visible(self, app):
2018-10-26 17:31:40 +02:00
return app in self.conf['apps'] and self.conf['apps'][app]['visible'] and tools.is_service_started(app)
2018-08-02 10:41:40 +02:00
class InvalidRecordException(Exception):
pass