235 lines
12 KiB
Python
235 lines
12 KiB
Python
|
# -*- coding: utf-8 -*-
|
||
|
|
||
|
import json
|
||
|
import os
|
||
|
|
||
|
from werkzeug.exceptions import BadRequest, HTTPException
|
||
|
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.mgr = AppMgr()
|
||
|
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 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.
|
||
|
if self.mgr.domain == 'spotter.vm':
|
||
|
return redirect('/setup-host')
|
||
|
return self.render_template('portal.html', conf=self.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()
|
||
|
return self.render_template('setup-host.html', conf=self.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.
|
||
|
return self.render_template('setup-apps.html', conf=self.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']
|
||
|
self.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
|
||
|
domains = [self.mgr.domain]+['{}.{}'.format(self.mgr.conf['apps'][app]['host'], self.mgr.domain) for app in self.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']
|
||
|
domains = [self.mgr.domain]+['{}.{}'.format(self.mgr.conf['apps'][app]['host'], self.mgr.domain) for app in self.mgr.conf['apps']]
|
||
|
for domain in domains:
|
||
|
host = '{}:{}'.format(domain, self.mgr.port) if proto == 'https' and self.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(self.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:
|
||
|
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')
|
||
|
self.mgr.install_cert('/tmp/public.pem', '/tmp/private.pem')
|
||
|
os.unlink('/tmp/public.pem')
|
||
|
os.unlink('/tmp/private.pem')
|
||
|
else:
|
||
|
self.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:
|
||
|
self.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:
|
||
|
if request.form['value'] == 'true':
|
||
|
self.mgr.show_tiles(request.form['app'])
|
||
|
else:
|
||
|
self.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:
|
||
|
if request.form['value'] == 'true':
|
||
|
self.mgr.enable_autostart(request.form['app'])
|
||
|
else:
|
||
|
self.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:
|
||
|
self.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:
|
||
|
self.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
|