472 lines
24 KiB
Python
472 lines
24 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
import json
|
|
import os
|
|
from cryptography.exceptions import InvalidSignature
|
|
from jinja2 import Environment, FileSystemLoader
|
|
from math import floor
|
|
from pkg_resources import parse_version
|
|
from spoc import repo_online, repo_local
|
|
from werkzeug.exceptions import HTTPException, NotFound, Unauthorized
|
|
from werkzeug.routing import Map, Rule, RequestRedirect
|
|
from werkzeug.utils import redirect
|
|
from werkzeug.wrappers import Request, Response
|
|
|
|
from . import config, crypto, net, remote, validator, vmmgr
|
|
from .actionqueue import ActionQueue, ActionItemType
|
|
from .wsgilang import WSGILang
|
|
from .wsgisession import WSGISession
|
|
|
|
SESSION_KEY = os.urandom(26)
|
|
|
|
class WSGIApp:
|
|
def __init__(self):
|
|
self.queue = ActionQueue()
|
|
self.jinja_env = Environment(loader=FileSystemLoader('/usr/share/vmmgr/templates'), autoescape=True, lstrip_blocks=True, trim_blocks=True)
|
|
self.url_map = Map((
|
|
Rule('/', endpoint='portal_view'),
|
|
Rule('/login', methods=['GET'], endpoint='login_view'),
|
|
Rule('/login', methods=['POST'], endpoint='login_action'),
|
|
Rule('/setup-host', redirect_to='/login?redir=setup-host'),
|
|
Rule('/setup-apps', redirect_to='/login?redir=setup-apps'),
|
|
Rule('/setup-remote', redirect_to='/login?redir=setup-remote'),
|
|
))
|
|
self.admin_url_map = Map((
|
|
Rule('/', endpoint='portal_view'),
|
|
Rule('/logout', endpoint='logout_action'),
|
|
Rule('/setup-host', endpoint='setup_host_view'),
|
|
Rule('/setup-apps', endpoint='setup_apps_view'),
|
|
Rule('/setup-remote', endpoint='setup_remote_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-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-app-status', endpoint='get_app_status_action'),
|
|
Rule('/clear-app-status', endpoint='clear_app_status_action'),
|
|
Rule('/uninstall-app', endpoint='uninstall_app_action'),
|
|
Rule('/update-app', endpoint='update_app_action'),
|
|
Rule('/update-password', endpoint='update_password_action'),
|
|
Rule('/shutdown-vm', endpoint='shutdown_vm_action'),
|
|
Rule('/reboot-vm', endpoint='reboot_vm_action'),
|
|
Rule('/update-ssh-keys', endpoint='update_ssh_keys_action'),
|
|
Rule('/update-vpn', endpoint='update_vpn_action'),
|
|
Rule('/generate-vpn-key', endpoint='generate_vpn_key_action'),
|
|
Rule('/start-vpn', endpoint='start_vpn_action'),
|
|
Rule('/stop-vpn', endpoint='stop_vpn_action'),
|
|
))
|
|
|
|
def __call__(self, environ, start_response):
|
|
return self.wsgi_app(environ, start_response)
|
|
|
|
def wsgi_app(self, environ, start_response):
|
|
request = Request(environ)
|
|
# Enhance request
|
|
request.session = WSGISession(request.cookies, SESSION_KEY)
|
|
request.session.lang = WSGILang()
|
|
# Dispatch request
|
|
response = self.dispatch_request(request)
|
|
return response(environ, start_response)
|
|
|
|
def dispatch_request(self, request):
|
|
url_map = self.admin_url_map if request.session['admin'] else self.url_map
|
|
adapter = url_map.bind_to_environ(request.environ)
|
|
try:
|
|
endpoint, values = adapter.match()
|
|
response = getattr(self, endpoint)(request, **values)
|
|
# Save session if changed
|
|
request.session.save(response)
|
|
return response
|
|
except NotFound as e:
|
|
# Return custom 404 page
|
|
response = self.render_html('404.html', request)
|
|
response.status_code = 404
|
|
return response
|
|
except HTTPException as e:
|
|
# Return MethodNotAllowed or RequestRedirect as they are
|
|
return e
|
|
|
|
def render_template(self, template_name, request, **context):
|
|
# Enhance context
|
|
context['config'] = config
|
|
context['session'] = request.session
|
|
context['lang'] = request.session.lang
|
|
# Render template
|
|
template = self.jinja_env.get_template(template_name)
|
|
return template.render(context)
|
|
|
|
def render_html(self, template_name, request, **context):
|
|
html = self.render_template(template_name, request, **context)
|
|
return Response(html, mimetype='text/html')
|
|
|
|
def render_json(self, data):
|
|
return Response(json.dumps(data), mimetype='application/json')
|
|
|
|
def get_session_message(self, request):
|
|
# Consume and retrieve message stored in session
|
|
if 'msg' not in request.session:
|
|
return None
|
|
message = request.session['msg']
|
|
del request.session['msg']
|
|
# Message is in format location:type:text
|
|
return message.split(':', 2)
|
|
|
|
def login_view(self, request):
|
|
redir = request.args.get('redir', '')
|
|
message = self.get_session_message(request)
|
|
return self.render_html('login.html', request, redir=redir, message=message)
|
|
|
|
def login_action(self, request):
|
|
password = request.form['password']
|
|
redir = request.form['redir']
|
|
hash = config.get_host()['adminpwd']
|
|
if crypto.adminpwd_verify(password, hash):
|
|
request.session['admin'] = True
|
|
return redirect(f'/{redir}')
|
|
request.session['msg'] = f'login:error:{request.session.lang.bad_password()}'
|
|
return redirect(f'/login?redir={redir}') if redir else redirect('/login')
|
|
|
|
def logout_action(self, request):
|
|
request.session.reset()
|
|
return redirect('/')
|
|
|
|
def portal_view(self, request):
|
|
# Default portal view.
|
|
host = config.get_host()
|
|
host = net.compile_url(host['domain'], host['port'], None)
|
|
apps = config.get_apps()
|
|
visible_apps = [app for app,definition in apps.items() if definition['visible'] and vmmgr.is_app_started(app)]
|
|
if request.session['admin']:
|
|
return self.render_html('portal-admin.html', request, host=host, apps=apps, visible_apps=visible_apps)
|
|
return self.render_html('portal-user.html', request, host=host, apps=apps, visible_apps=visible_apps)
|
|
|
|
def setup_host_view(self, request):
|
|
# Host setup view.
|
|
ex_ipv4 = net.get_external_ip(4)
|
|
ex_ipv6 = net.get_external_ip(6)
|
|
in_ipv4 = net.get_local_ip(4)
|
|
in_ipv6 = net.get_local_ip(6)
|
|
cert_info = crypto.get_cert_info()
|
|
apps = config.get_apps()
|
|
host = config.get_host()
|
|
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, apps=apps, domain=host['domain'], port=host['port'])
|
|
|
|
def setup_apps_view(self, request):
|
|
# Application manager view.
|
|
repo_error = None
|
|
try:
|
|
# Repopulate online_repo cache or fail early when the repo can't be reached
|
|
repo_online.load(True)
|
|
except InvalidSignature:
|
|
repo_error = request.session.lang.invalid_packages_signature()
|
|
except Unauthorized:
|
|
repo_error = request.session.lang.repo_invalid_credentials()
|
|
except:
|
|
repo_error = request.session.lang.repo_unavailable()
|
|
table = self.render_setup_apps_table(request)
|
|
message = self.get_session_message(request)
|
|
repo_conf = vmmgr.get_repo_conf()
|
|
return self.render_html('setup-apps.html', request, repo_conf=repo_conf, repo_error=repo_error, table=table, message=message)
|
|
|
|
def render_setup_apps_table(self, request):
|
|
lang = request.session.lang
|
|
local_apps = repo_local.get_apps()
|
|
try:
|
|
online_apps = repo_online.get_apps()
|
|
except:
|
|
online_apps = {}
|
|
apps_config = config.get_apps()
|
|
actionable_apps = sorted(set(online_apps) | set(local_apps))
|
|
pending_actions = self.queue.get_actions()
|
|
app_data = {}
|
|
for app in actionable_apps:
|
|
installed = app in local_apps
|
|
title = local_apps[app]['meta']['title'] if installed else online_apps[app]['meta']['title']
|
|
try:
|
|
visible = apps_config[app]['visible']
|
|
except KeyError:
|
|
visible = False
|
|
try:
|
|
autostarted = local_apps[app]['autostart']
|
|
except KeyError:
|
|
autostarted = False
|
|
if app in pending_actions:
|
|
# Display queued or currently processed actions
|
|
app_queue = pending_actions[app]
|
|
if app_queue.index:
|
|
if app_queue.exception:
|
|
# Display failed task
|
|
if isinstance(app_queue.exception, InvalidSignature):
|
|
status = lang.repo_package_invalid_signature()
|
|
elif isinstance(app_queue.exception, NotFound):
|
|
status = lang.repo_package_missing()
|
|
elif isinstance(app_queue.exception, BaseException):
|
|
if app_queue.action in (vmmgr.start_app, vmmgr.stop_app):
|
|
status = lang.stop_start_error()
|
|
else:
|
|
status = lang.package_manager_error()
|
|
status = f'<span class="error">{status}<span> <a href="#" class="app-clear-status">OK</a>'
|
|
actions = None
|
|
else:
|
|
# Display task/subtask progress
|
|
action_item = app_queue.queue[app_queue.index-1]
|
|
if action_item.type in (ActionItemType.IMAGE_DOWNLOAD, ActionItemType.APP_DOWNLOAD):
|
|
status = lang.status_downloading(action_item.key)
|
|
elif action_item.type in (ActionItemType.IMAGE_UNPACK, ActionItemType.APP_UNPACK):
|
|
status = lang.status_unpacking(action_item.key)
|
|
elif action_item.type == ActionItemType.IMAGE_DELETE:
|
|
status = lang.status_deleting(action_item.key)
|
|
elif action_item.type == ActionItemType.APP_START:
|
|
status = lang.status_starting()
|
|
elif action_item.type == ActionItemType.APP_STOP:
|
|
status = lang.status_stopping()
|
|
elif action_item.type == ActionItemType.APP_INSTALL:
|
|
status = lang.status_installing()
|
|
elif action_item.type == ActionItemType.APP_UPDATE:
|
|
status = lang.status_updating()
|
|
elif action_item.type == ActionItemType.APP_UNINSTALL:
|
|
status = lang.status_uninstalling()
|
|
if app_queue.action not in (vmmgr.start_app, vmmgr.stop_app):
|
|
# For tasks other than start/stop which have only a single subtask, display also index of the subtask in queue
|
|
status = f'[{app_queue.index}/{len(app_queue.queue)}] {status}'
|
|
if action_item.units_total:
|
|
# Show progress for tasks which have measurable progress
|
|
status = f'{status} ({floor(action_item.units_done/action_item.units_total*100)} %)'
|
|
actions = '<div class="loader"></div>'
|
|
else:
|
|
# Display queued (pending, not started) task
|
|
if app_queue.action == vmmgr.start_app:
|
|
status = lang.status_starting()
|
|
elif app_queue.action == vmmgr.stop_app:
|
|
status = lang.status_stopping()
|
|
elif app_queue.action == vmmgr.install_app:
|
|
status = lang.status_installing()
|
|
elif app_queue.action == vmmgr.update_app:
|
|
status = lang.status_updating()
|
|
elif app_queue.action == vmmgr.uninstall_app:
|
|
status = lang.status_uninstalling()
|
|
status = f'{status} ({lang.status_queued()})'
|
|
actions = '<div class="loader"></div>'
|
|
else:
|
|
# Diplay apps with no queued or currently processed action
|
|
if not installed:
|
|
status = lang.status_not_installed()
|
|
actions = f'<a href="#" class="app-install">{lang.action_install()}</a>'
|
|
else:
|
|
if vmmgr.is_app_started(app):
|
|
status = f'<span class="info">{lang.status_started()}</span>'
|
|
actions = f'<a href="#" class="app-stop">{lang.action_stop()}</a>'
|
|
else:
|
|
status = f'<span class="error">{lang.status_stopped()}</span>'
|
|
actions = f'<a href="#" class="app-start">{lang.action_start()}</a>, <a href="#" class="app-uninstall">{lang.action_uninstall()}</a>'
|
|
try:
|
|
if parse_version(online_apps[app]['version']) > parse_version(local_apps[app]['version']):
|
|
actions = f'{actions}, <a href="#" class="app-update">{lang.action_update()}</a>'
|
|
except KeyError:
|
|
pass
|
|
app_data[app] = {'title': title, 'visible': visible, 'installed': installed, 'autostarted': autostarted, 'status': status, 'actions': actions}
|
|
return self.render_template('setup-apps-table.html', request, app_data=app_data)
|
|
|
|
def setup_remote_view(self, request):
|
|
# Remote access setup view
|
|
authorized_keys = remote.get_authorized_keys()
|
|
wg_conf = remote.get_wireguard_conf()
|
|
wg_conf['status'] = ('info', request.session.lang.status_started()) if wg_conf['running'] else ('error', request.session.lang.status_stopped())
|
|
message = self.get_session_message(request)
|
|
return self.render_html('setup-remote.html', request, authorized_keys=authorized_keys, wg_conf=wg_conf, message=message)
|
|
|
|
def update_host_action(self, request):
|
|
# Update domain and port, then restart nginx
|
|
domain = request.form['domain']
|
|
port = request.form['port']
|
|
if not validator.is_valid_domain(domain):
|
|
return self.render_json({'error': request.session.lang.invalid_domain()})
|
|
if not validator.is_valid_port(port):
|
|
return self.render_json({'error': request.session.lang.invalid_port()})
|
|
vmmgr.update_host(domain, port)
|
|
url = f'{net.compile_url(net.get_local_ip(), port)}/setup-host'
|
|
response = self.render_json({'ok': request.session.lang.host_updated(url, url)})
|
|
response.call_on_close(vmmgr.restart_nginx)
|
|
return response
|
|
|
|
def verify_dns_action(self, request):
|
|
# Check if all FQDNs for all applications are resolvable and point to current external IP
|
|
domain = config.get_host()['domain']
|
|
domains = [domain]+[f'{definition["host"]}.{domain}' for app,definition in config.get_apps().items()]
|
|
ipv4 = net.get_external_ip(4)
|
|
ipv6 = net.get_external_ip(6)
|
|
for domain in domains:
|
|
try:
|
|
a = net.resolve_ip(domain, 'A')
|
|
aaaa = net.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']
|
|
host = config.get_host()
|
|
port = host['port'] if proto == 'https' else '80'
|
|
domains = [host['domain']]+[f'{definition["host"]}.{host["domain"]}' for app,definition in config.get_apps().items()]
|
|
for domain in domains:
|
|
url = net.compile_url(domain, port, proto)
|
|
try:
|
|
if not net.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
|
|
if request.form['method'] == 'selfsigned':
|
|
vmmgr.create_selfsigned_cert()
|
|
elif request.form['method'] == 'automatic':
|
|
vmmgr.request_acme_cert()
|
|
elif request.form['method'] == 'manual':
|
|
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')
|
|
vmmgr.install_manual_cert('/tmp/public.pem', '/tmp/private.pem')
|
|
os.unlink('/tmp/public.pem')
|
|
os.unlink('/tmp/private.pem')
|
|
else:
|
|
return self.render_json({'error': request.session.lang.cert_request_error()})
|
|
host = config.get_host()
|
|
url = net.compile_url(host['domain'], host['port'])
|
|
return self.render_json({'ok': request.session.lang.cert_installed(url, url)})
|
|
|
|
def update_repo_action(self, request):
|
|
# Update repository URL and credentials
|
|
url = request.form['repourl']
|
|
if not validator.is_valid_repo_url(url):
|
|
request.session['msg'] = f'repo:error:{request.session.lang.invalid_url(url)}'
|
|
else:
|
|
vmmgr.update_repo_conf(url, request.form['repousername'], request.form['repopassword'])
|
|
request.session['msg'] = f'repo:info:{request.session.lang.repo_updated()}'
|
|
return redirect('/setup-apps')
|
|
|
|
def update_app_visibility_action(self, request):
|
|
# Update application visibility on portal page
|
|
vmmgr.update_app_visibility(request.form['app'], request.form['value'] == 'true')
|
|
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
|
|
vmmgr.update_app_autostart(request.form['app'], request.form['value'] == 'true')
|
|
return self.render_json({'ok': 'ok'})
|
|
|
|
def enqueue_app_action(self, request, action):
|
|
# Common method for enqueuing app actions
|
|
app = request.form['app']
|
|
self.queue.enqueue_action(app, action)
|
|
response = self.render_json({'ok': self.render_setup_apps_table(request)})
|
|
response.call_on_close(self.queue.process)
|
|
return response
|
|
|
|
def start_app_action(self, request):
|
|
# Queues application start along with its dependencies
|
|
return self.enqueue_app_action(request, vmmgr.start_app)
|
|
|
|
def stop_app_action(self, request):
|
|
# Queues application stop along with its dependencies
|
|
return self.enqueue_app_action(request, vmmgr.stop_app)
|
|
|
|
def install_app_action(self, request):
|
|
# Queues application installation
|
|
return self.enqueue_app_action(request, vmmgr.install_app)
|
|
|
|
def uninstall_app_action(self, request):
|
|
# Queues application uninstallation
|
|
return self.enqueue_app_action(request, vmmgr.uninstall_app)
|
|
|
|
def update_app_action(self, request):
|
|
# Queues application update
|
|
return self.enqueue_app_action(request, vmmgr.update_app)
|
|
|
|
def get_app_status_action(self, request):
|
|
# Gets application and queue status
|
|
return self.render_json({'ok': self.render_setup_apps_table(request)})
|
|
|
|
def clear_app_status_action(self, request):
|
|
# Clears error status for an application
|
|
self.queue.clear_action(request.form['app'])
|
|
return self.render_json({'ok': self.render_setup_apps_table(request)})
|
|
|
|
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, vmmgr.update_password will raise exception if it's wrong
|
|
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(vmmgr.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(vmmgr.shutdown_vm)
|
|
return response
|
|
|
|
def update_ssh_keys_action(self, request):
|
|
# Update authorized_keys file
|
|
remote.set_authorized_keys(request.form['ssh-keys'].replace('\r', ''))
|
|
request.session['msg'] = f'ssh:info:{request.session.lang.ssh_keys_installed()}'
|
|
return redirect('/setup-remote')
|
|
|
|
def update_vpn_action(self, request):
|
|
# Update WireGuard VPN listen port, virtual IP and peer list
|
|
ip = request.form['vpn-lip']
|
|
if not ip.isdigit() or not 0 < int(ip) < 255:
|
|
request.session['msg'] = f'vpn:error:{request.session.lang.invalid_ip()}'
|
|
return redirect('/setup-remote')
|
|
port = request.form['vpn-port']
|
|
if not port.isdigit() or not 0 < int(port) < 65536:
|
|
request.session['msg'] = f'vpn:error:{request.session.lang.invalid_port()}'
|
|
return redirect('/setup-remote')
|
|
peers = request.form['vpn-peers'].replace('\r', '')
|
|
remote.set_wireguard_conf(ip, port, peers)
|
|
request.session['msg'] = f'vpn:info:{request.session.lang.vpn_updated()}'
|
|
return redirect('/setup-remote')
|
|
|
|
def generate_vpn_key_action(self, request):
|
|
# Regenerate WireGuard key pair
|
|
remote.regenerate_wireguard_key()
|
|
return redirect('/setup-remote')
|
|
|
|
def start_vpn_action(self, request):
|
|
# Start WireGuard VPN
|
|
remote.start_wireguard()
|
|
return redirect('/setup-remote')
|
|
|
|
def stop_vpn_action(self, request):
|
|
# Stop WireGuard VPN
|
|
remote.stop_wireguard()
|
|
return redirect('/setup-remote')
|