Spotter-VM/basic/srv/spotter/appmgr/wsgiapp.py

250 lines
12 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 AppMgr
from . import tools
from .validator import InvalidValueException
class Lang:
lang = {
'malformed_request': 'Byl zaslán chybný požadavek. Obnovte stránku a zkuste akci zopakovat.',
'invalid_domain': 'Zadaný doménový název "{}" není platný.',
'invalid_port': 'Zadaný port "{}" není platný.',
'host_updated': 'Nastavení hostitele bylo úspěšně změněno. Přejděte na URL <a href="{}">{}</a> a pokračujte následujícími kroky.',
'dns_record_does_not_exist': 'DNS záznam pro název "{}" neexistuje.',
'dns_record_mismatch': 'DNS záznam pro název "{}" směřuje na IP {} místo očekávané {}.',
'dns_timeout': 'Nepodařilo se kontaktovat DNS server. Zkontrolujte, zda má virtuální stroj přístup k internetu.',
'dns_records_ok': 'DNS záznamy jsou nastaveny správně.',
'http_host_not_reachable': 'Adresa {} není dostupná z internetu. Zkontrolujte nastavení síťových komponent.',
'http_timeout': 'Nepodařilo se kontaktovat ping server. Zkontrolujte, zda má virtuální stroj přístup k internetu.',
'http_hosts_ok': 'Síť je nastavena správně. Všechny aplikace na portu {} jsou z internetu dostupné.',
'cert_file_missing': 'Nebyl vybrán soubor s certifikátem.',
'key_file_missing': 'Nebyl vybrán soubor se soukromým klíčem.',
'cert_request_error': 'Došlo k chybě při žádosti o certifikát. Zkontrolujte, zda je virtuální stroj dostupný z internetu na portu 80.',
'cert_installed': 'Certifikát byl úspěšně nainstalován. Restartujte webový prohlížeč pro jeho načtení.',
'common_updated': 'Nastavení aplikací bylo úspěšně změněno.',
'app_started': '<span class="info">Spuštěna</span> (<a href="#" class="app-stop">zastavit</a>)',
'app_stopped': '<span class="error">Zastavena</span> (<a href="#" class="app-start">spustit</a>)',
'stop_start_error': 'Došlo k chybě při spouštění/zastavování. Zkuste akci opakovat nebo restartuje virtuální stroj.',
}
def __getattr__(self, key):
def function(*args):
return self.lang[key].format(*args)
return function
class WSGIApp(object):
def __init__(self):
self.lang = Lang()
self.jinja_env = Environment(loader=FileSystemLoader('/srv/spotter/templates'), autoescape=True, lstrip_blocks=True, trim_blocks=True)
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)
response = self.dispatch_request(request)
response = response(environ, start_response)
# Defer nginx restart for /update-host request
if request.path == '/update-host':
return ClosingIterator(response, tools.restart_nginx)
return response
def dispatch_request(self, request):
map = Map([
Rule('/', endpoint='portal_view'),
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'),
])
adapter = map.bind_to_environ(request.environ)
try:
endpoint, values = adapter.match()
return getattr(self, endpoint)(request, **values)
except NotFound as e:
response = self.render_template('404.html')
response.status_code = 404
return response
except HTTPException as e:
return e
def render_template(self, template_name, **context):
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 portal_view(self, request):
# Default view. If domain is set to the default dummy domain, redirects to first-run setup instead.
mgr = AppMgr()
if mgr.domain == 'spotter.vm':
return redirect('/setup-host')
return self.render_template('portal.html', conf=mgr.conf)
def setup_host_view(self, request):
# First-run 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()
is_letsencrypt = os.path.exists('/etc/periodic/daily/acme-sh')
cert_info = tools.get_cert_info()
mgr = AppMgr()
return self.render_template('setup-host.html', conf=mgr.conf, ex_ipv4=ex_ipv4, ex_ipv6=ex_ipv6, in_ipv4=in_ipv4, in_ipv6=in_ipv6, is_letsencrypt=is_letsencrypt, cert_info=cert_info)
def setup_apps_view(self, request):
# Application manager view.
mgr = AppMgr()
return self.render_template('setup-apps.html', conf=mgr.conf)
def update_host_action(self, request):
# Update domain and port, then restart nginx (done via ClosingIterator in self.wsgi_app())
try:
domain = request.form['domain']
port = request.form['port']
mgr = AppMgr()
mgr.update_host(domain, port, False)
server_name = request.environ['HTTP_X_FORWARDED_SERVER_NAME']
url = 'https://{}/setup-host'.format('{}:{}'.format(server_name, port) if port != '443' else server_name)
return self.render_json({'ok': self.lang.host_updated(url, url)})
except BadRequest:
return self.render_json({'error': self.lang.malformed_request()})
except InvalidValueException as e:
if e.args[0] == 'domain':
return self.render_json({'error': self.lang.invalid_domain(domain)})
if e.args[0] == 'port':
return self.render_json({'error': self.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
mgr = AppMgr()
domains = [mgr.domain]+['{}.{}'.format(mgr.conf['apps'][app]['host'], mgr.domain) for app in mgr.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': self.lang.dns_record_does_not_exist(domain)})
if a and a != ipv4:
return self.render_json({'error': self.lang.dns_record_mismatch(domain, a, ipv4)})
if aaaa and aaaa != ipv6:
return self.render_json({'error': self.lang.dns_record_mismatch(domain, aaaa, ipv6)})
except:
return self.render_json({'error': self.lang.dns_timeout()})
return self.render_json({'ok': self.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']
mgr = AppMgr()
domains = [mgr.domain]+['{}.{}'.format(mgr.conf['apps'][app]['host'], mgr.domain) for app in mgr.conf['apps']]
for domain in domains:
host = '{}:{}'.format(domain, mgr.port) if proto == 'https' and mgr.port != '443' else domain
url = '{}://{}/'.format(proto, host)
try:
if not tools.ping_url(url):
return self.render_json({'error': self.lang.http_host_not_reachable(url)})
except:
return self.render_json({'error': self.lang.http_timeout()})
return self.render_json({'ok': self.lang.http_hosts_ok(mgr.port if proto == 'https' else '80')})
def update_cert_action(self, request):
# Update certificate - either request via Let's Encrypt or manually upload files
try:
mgr = AppMgr()
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': self.lang.cert_file_missing()})
if not request.files['private']:
return self.render_json({'error': self.lang.key_file_missing()})
request.files['public'].save('/tmp/public.pem')
request.files['private'].save('/tmp/private.pem')
mgr.install_cert('/tmp/public.pem', '/tmp/private.pem')
os.unlink('/tmp/public.pem')
os.unlink('/tmp/private.pem')
else:
mgr.request_cert()
except BadRequest:
return self.render_json({'error': self.lang.malformed_request()})
except:
return self.render_json({'error': self.lang.cert_request_error()})
return self.render_json({'ok': self.lang.cert_installed()})
def update_common_action(self, request):
try:
mgr = AppMgr()
mgr.update_common(request.form['email'], request.form['gmaps-api-key'])
except BadRequest:
return self.render_json({'error': self.lang.malformed_request()})
return self.render_json({'ok': self.lang.common_updated()})
def update_app_visibility_action(self, request):
try:
mgr = AppMgr()
if request.form['value'] == 'true':
mgr.show_tiles(request.form['app'])
else:
mgr.hide_tiles(request.form['app'])
except (BadRequest, InvalidValueException):
return self.render_json({'error': self.lang.malformed_request()})
return self.render_json({'ok': 'ok'})
def update_app_autostart_action(self, request):
try:
mgr = AppMgr()
if request.form['value'] == 'true':
mgr.enable_autostart(request.form['app'])
else:
mgr.disable_autostart(request.form['app'])
except (BadRequest, InvalidValueException):
return self.render_json({'error': self.lang.malformed_request()})
return self.render_json({'ok': 'ok'})
def start_app_action(self, request):
try:
mgr = AppMgr()
mgr.start_app(request.form['app'])
except (BadRequest, InvalidValueException):
return self.render_json({'error': self.lang.malformed_request()})
except:
return self.render_json({'error': self.lang.stop_start_error()})
return self.render_json({'ok': self.lang.app_started()})
def stop_app_action(self, request):
try:
mgr = AppMgr()
mgr.stop_app(request.form['app'])
except (BadRequest, InvalidValueException):
return self.render_json({'error': self.lang.malformed_request()})
except:
return self.render_json({'error': self.lang.stop_start_error()})
return self.render_json({'ok': self.lang.app_stopped()})
class InvalidRecordException(Exception):
pass