Initial refactor commit after SPOC

This commit is contained in:
Disassembler 2020-03-29 20:56:50 +02:00
parent f5501c0605
commit fb38e535e1
No known key found for this signature in database
GPG Key ID: 524BD33A0EE29499
14 changed files with 563 additions and 486 deletions

View File

@ -1,14 +1,69 @@
# -*- coding: utf-8 -*-
from enum import Enum
from collections import deque
from threading import Lock
from spoc.config import LOCK_FILE
from spoc.flock import locked
class ActionItemType(Enum):
IMAGE_DOWNLOAD = 1
IMAGE_UNPACK = 2
IMAGE_DELETE = 3
APP_DOWNLOAD = 4
APP_UNPACK = 5
APP_INSTALL = 6
APP_UPDATE = 7
APP_UNINSTALL = 8
class ActionItem:
def __init__(self, key, action):
def __init__(self, type, key, action, show_progress=True):
self.type = type
self.key = key
self.action = action
self.show_progress = show_progress
self.units_total = 1
self.units_done = 0
def run(self):
if self.show_progress:
self.action(self)
else:
self.action()
self.units_done = 1
class ActionAppQueue:
def __init__(self, action):
self.action = action
self.queue = []
self.started = False
self.data = None
self.exception = None
self.index = 0
def download_image(self, image):
self.queue.append(ActionItem(ActionItemType.IMAGE_DOWNLOAD, image.name, image.download))
self.queue.append(ActionItem(ActionItemType.IMAGE_UNPACK, image.name, image.unpack_downloaded))
def delete_image(self, image):
self.queue.append(ActionItem(ActionItemType.IMAGE_DELETE, image.name, image.delete, False))
def install_app(self, app):
self.queue.append(ActionItem(ActionItemType.APP_DOWNLOAD, app.name, app.download))
self.queue.append(ActionItem(ActionItemType.APP_UNPACK, app.name, app.unpack_downloaded))
self.queue.append(ActionItem(ActionItemType.APP_INSTALL, app.name, app.install, False))
def update_app(self, app):
self.queue.append(ActionItem(ActionItemType.APP_DOWNLOAD, app.name, app.download))
self.queue.append(ActionItem(ActionItemType.APP_UNPACK, app.name, app.unpack_downloaded))
self.queue.append(ActionItem(ActionItemType.APP_UPDATE, app.name, app.update, False))
def uninstall_app(self, app):
self.queue.append(ActionItem(ActionItemType.APP_UNINSTALL, app.name, app.uninstall, False))
def process(self):
for item in self.queue:
self.index += 1
item.run()
class ActionQueue:
def __init__(self):
@ -18,20 +73,21 @@ class ActionQueue:
self.is_running = False
def get_actions(self):
# Return copy of actions, so they can be traversed without state changes
# Return copy of actions, so they can be read and traversed without state changes
with self.lock:
return self.actions.copy()
def enqueue_action(self, key, action):
def enqueue_action(self, app_name, action):
# Enqueue action
with self.lock:
if key in self.actions:
# If the key alredy has a pending action, reject any other actions
if app_name in self.actions:
# If the app already has a pending action, reject any other actions
return
item = ActionItem(key, action)
self.actions[key] = item
self.queue.append(item)
# Create empty queue to be populated with actions just before execution
self.actions[app_name] = ActionAppQueue(action)
self.queue.append(app_name)
@locked(LOCK_FILE)
def process_actions(self):
# Main method for deferred queue processing called by WSGI close handler
with self.lock:
@ -41,28 +97,32 @@ class ActionQueue:
while True:
with self.lock:
# Try to get an item from queue
item = None
app_name = None
if self.queue:
item = self.queue.popleft()
app_name = self.queue.popleft()
# If there are no more queued items, unset the processing flag and allow the thread to be terminated
if not item:
if not app_name:
self.is_running = False
return
# If there is an item to be processed, set processing flags and exit the lock
self.is_running = True
item.started = True
app_queue = self.actions[app_name]
try:
# Call the method passed in item.action with the whole item as parameter
item.action(item)
# If the action finished without errors, restore nominal state by deleting the item from action list
self.clear_action(item.key)
# Call the method passed in app_queue.action to populate the queue of the actual actions to be taken in relation to the current local repository state
app_queue.action(app_name, app_queue)
# Process the freshly populated queue of actions related to the particular app
app_queue.process()
# If the actions finished without errors, restore nominal state by deleting the item from action list
self.clear_action(app_name)
except BaseException as e:
# If the action failed, store the exception and leave it in the list for manual clearance
with self.lock:
item.data = e
app_queue.exception = e
def clear_action(self, key):
def clear_action(self, app_name):
# Restore nominal state by deleting the item from action list
with self.lock:
if key in self.actions:
del self.actions[key]
try:
del self.actions[app_name]
except KeyError:
pass

View File

@ -1,100 +0,0 @@
# -*- coding: utf-8 -*-
import os
import subprocess
#from .pkgmgr import Pkg, PkgMgr
class AppMgr:
def __init__(self, conf):
self.conf = conf
self.pkgmgr = None #PkgMgr(conf)
def start_app(self, item):
# Start the actual app service
app = item.key
if app in self.conf['apps'] and not self.is_service_started(app):
self.start_service(app)
def start_service(self, service):
subprocess.run(['/sbin/service', service, 'start'], check=True)
def stop_app(self, item):
# Stop the actual app service
app = item.key
if app in self.conf['apps'] and self.is_service_started(app):
self.stop_service(app)
# Stop the app service's dependencies if they are not used by another running app
deps = self.get_services_deps()
for dep in self.get_service_deps(app):
if not any([self.is_service_started(d) for d in deps[dep]]):
self.stop_service(dep)
def stop_service(self, service):
subprocess.run(['/sbin/service', service, 'stop'], check=True)
def update_app_visibility(self, app, visible):
# Update visibility for the app in the configuration
if app in self.conf['apps']:
self.conf['apps'][app]['visible'] = visible
self.conf.save()
def update_app_autostart(self, app, enabled):
# Add/remove the app to OpenRC default runlevel
if app in self.conf['apps']:
subprocess.run(['/sbin/rc-update', 'add' if enabled else 'del', app])
def is_service_started(self, app):
# Check OpenRC service status without calling any binary
return os.path.exists(os.path.join('/run/openrc/started', app))
def is_service_autostarted(self, app):
# Check OpenRC service enablement
return os.path.exists(os.path.join('/etc/runlevels/default', app))
def install_app(self, item):
# Main installation function. Wrapper for download, registration and install script
item.data = None #Pkg()
self.pkgmgr.install_app(item.key, item.data)
def uninstall_app(self, item):
# Main uninstallation function. Wrapper for uninstall script, filesystem purge and unregistration
app = item.key
self.stop_app(item)
if self.is_service_autostarted(app):
self.update_app_autostart(app, False)
self.pkgmgr.uninstall_app(app)
def update_app(self, item):
# Main update function. Wrapper for download and update script
self.stop_app(item)
item.data = None #Pkg()
self.pkgmgr.update_app(item.key, item.data)
def get_services_deps(self):
# Fisrt, build a dictionary of {app: [needs]}
needs = {}
for app in self.conf['apps'].copy():
needs[app] = self.get_service_deps(app)
# Then reverse it to {need: [apps]}
deps = {}
for app, need in needs.items():
for n in need:
deps.setdefault(n, []).append(app)
return deps
def get_service_deps(self, app):
# Get "need" line from init script and split it to a list
try:
with open(os.path.join('/etc/init.d', app), 'r') as f:
return [l for l in f.readlines() if l.strip().startswith('need')][0].split()[1:]
except:
pass
return []
def update_repo_settings(self, url, user, pwd):
# Update lxc repository configuration
self.conf['repo']['url'] = url
self.conf['repo']['user'] = user
self.conf['repo']['pwd'] = pwd
self.conf.save()

View File

@ -1,27 +1,83 @@
# -*- coding: utf-8 -*-
import fcntl
import json
import os
from spoc.flock import locked
from .paths import CONF_FILE, CONF_LOCK
class Config:
def __init__(self):
self.load()
data = {}
mtime = None
def load(self):
# Load configuration from file. Uses file lock as interprocess mutex
with open(CONF_LOCK, 'w') as lock:
fcntl.lockf(lock, fcntl.LOCK_EX)
with open(CONF_FILE, 'r') as f:
self.data = json.load(f)
def load():
global data
global mtime
file_mtime = os.stat(CONF_FILE).st_mtime
if mtime != file_mtime:
with open(CONF_FILE, 'r') as f:
data = json.load(f)
mtime = file_mtime
def save(self):
# Save configuration to a file. Uses file lock as interprocess mutex
with open(CONF_LOCK, 'w') as lock:
fcntl.lockf(lock, fcntl.LOCK_EX)
with open(CONF_FILE, 'w') as f:
json.dump(self.data, f, sort_keys=True, indent=4)
def save():
global mtime
with open(CONF_FILE, 'w') as f:
json.dump(data, f, sort_keys=True, indent=4)
mtime = os.stat(CONF_FILE).st_mtime
def __getitem__(self, attr):
return self.data[attr]
@locked(CONF_LOCK)
def get_entries(attr):
load()
return data[attr]
@locked(CONF_LOCK)
def add_entry(entry_type, name, definition):
load()
data[entry_type][name] = definition
save()
@locked(CONF_LOCK)
def delete_entry(entry_type, name):
load()
try:
del data[entry_type][name]
save()
except KeyError:
pass
def get_apps():
return get_entries('apps')
def get_common():
return get_entries('common')
def get_host():
host = get_entries('host')
return (host['domain'], host['port'])
def get_adminpwd():
return get_entries('host')['adminpwd']
def register_app(app_name, definition):
add_entry('apps', app_name, definition)
def unregister_app(app_name):
delete_entry('apps', app_name)
def set_common(key, value):
add_entry('common', key, value)
@locked(CONF_LOCK)
def set_host(domain, port):
load()
data['host']['domain'] = domain
data['host']['port'] = port
save()
def set_adminpwd(hash):
add_entry('host', 'adminpwd', hash)
@locked(CONF_LOCK)
def set_app(app_name, key, value):
load()
data['apps'][app_name][key] = value
save()

View File

@ -3,17 +3,18 @@
import bcrypt
import datetime
import os
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID
from . import config
from .paths import ACME_CRON, CERT_PUB_FILE, CERT_KEY_FILE
def create_selfsigned_cert(domain):
def create_selfsigned_cert():
# Create selfsigned certificate with wildcard alternative subject name
domain = config.get_host()[0]
private_key = ec.generate_private_key(ec.SECP384R1(), default_backend())
public_key = private_key.public_key()
subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, domain)])
@ -25,7 +26,7 @@ def create_selfsigned_cert(domain):
.serial_number(x509.random_serial_number()) \
.not_valid_before(now) \
.not_valid_after(now + datetime.timedelta(days=7305)) \
.add_extension(x509.SubjectAlternativeName((x509.DNSName(domain), x509.DNSName('*.{}'.format(domain)))), critical=False) \
.add_extension(x509.SubjectAlternativeName((x509.DNSName(domain), x509.DNSName(f'*.{domain}'))), critical=False) \
.add_extension(x509.SubjectKeyIdentifier.from_public_key(public_key), critical=False) \
.add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(public_key), critical=False) \
.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) \
@ -44,7 +45,7 @@ def get_cert_info():
cert = x509.load_pem_x509_certificate(f.read(), default_backend())
data = {'subject': cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value,
'issuer': cert.issuer.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value,
'expires': '{} UTC'.format(cert.not_valid_after),
'expires': f'{cert.not_valid_after} UTC',
'method': 'manual'}
if os.access(ACME_CRON, os.X_OK):
data['method'] = 'automatic'
@ -58,5 +59,5 @@ def get_cert_info():
def adminpwd_hash(password):
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def adminpwd_verify(password, pwhash):
return bcrypt.checkpw(password.encode(), pwhash.encode())
def adminpwd_verify(password):
return bcrypt.checkpw(password.encode(), config.get_adminpwd().encode())

View File

@ -9,8 +9,8 @@ import subprocess
from .paths import MYIP_URL, PING_URL
def compile_url(domain, port, proto='https'):
port = '' if (proto == 'https' and port == '443') or (proto == 'http' and port == '80') else ':{}'.format(port)
return '{}://{}{}'.format(proto, domain, port)
port = '' if (proto in (None, 'https') and port == '443') or (proto == 'http' and port == '80') else f':{port}'
return f'{proto}://{domain}{port}' if proto else f'{domain}{port}'
def get_local_ip(version=None):
# Return first routable IPv4/6 address of the VM (container host)

View File

@ -24,4 +24,3 @@ WG_CONF_FILE_DISABLED = '/etc/wireguard/wg0.conf.disabled'
# URLs
MYIP_URL = 'https://repo.spotter.cz/tools/myip.php'
PING_URL = 'https://repo.spotter.cz/tools/vm-ping.php'
RELOAD_URL = 'http://127.0.0.1:8080/reload-config'

View File

@ -1,10 +1,8 @@
# -*- coding: utf-8 -*-
import configparser
import os
import subprocess
from . import templates
from .paths import AUTHORIZED_KEYS, INTERFACES_FILE, WG_CONF_FILE, WG_CONF_FILE_DISABLED
def get_authorized_keys():
@ -38,7 +36,7 @@ def regenerate_wireguard_key():
privkey = subprocess.run(['wg', 'genkey'], stdout=subprocess.PIPE).stdout.strip().decode()
with open(WG_CONF_FILE_DISABLED) as f:
conf_lines = f.readlines()
conf_lines[2] = 'PrivateKey = {}\n'.format(privkey)
conf_lines[2] = f'PrivateKey = {privkey}\n'
with open(WG_CONF_FILE_DISABLED, 'w') as f:
f.writelines(conf_lines)
if was_running:
@ -77,14 +75,14 @@ def set_wireguard_conf(ip, port, peers):
with open(INTERFACES_FILE) as f:
for line in f.readlines():
if '172.17.255' in line:
line = ' address 172.17.255.{}\n'.format(ip)
line = f' address 172.17.255.{ip}\n'
interface_lines.append(line)
with open(INTERFACES_FILE, 'w') as f:
f.writelines(interface_lines)
# Recreate config (listen port and peers)
with open(WG_CONF_FILE_DISABLED) as f:
conf_lines = f.readlines()[:4]
conf_lines[1] = 'ListenPort = {}\n'.format(port)
conf_lines[1] = f'ListenPort = {port}\n'
with open(WG_CONF_FILE_DISABLED, 'w') as f:
f.writelines(conf_lines)
f.write(peers)

View File

@ -1,149 +1,212 @@
# -*- coding: utf-8 -*-
import json
import configparser
import os
import requests
import shutil
import subprocess
import urllib
from spoc.app import App
from spoc.config import ONLINE_BASE_URL
from spoc.container import Container, ContainerState
from . import crypto
from . import templates
from . import net
from .paths import ACME_CRON, ACME_DIR, ISSUE_FILE, MOTD_FILE, NGINX_DIR, RELOAD_URL
from . import crypto, net, templates
from .paths import ACME_CRON, ACME_DIR, ISSUE_FILE, MOTD_FILE, NGINX_DIR
class VMMgr:
def __init__(self, conf):
# Load JSON configuration
self.conf = conf
self.domain = conf['host']['domain']
self.port = conf['host']['port']
def register_app(app, host, login, password):
# Register newly installed application, its subdomain and credentials (called at the end of package install.sh)
config.register_app(app, {
'host': host,
'login': login if login else 'N/A',
'password': password if password else 'N/A',
'visible': False,
})
def register_app(self, app, host, login, password):
# Register newly installed application, its subdomain and credentials (called at the end of package install.sh)
self.conf['apps'][app] = {'host': host,
'login': login if login else 'N/A',
'password': password if password else 'N/A',
'visible': False}
self.conf.save()
self.reload_wsgi_config()
def unregister_app(app):
config.unregister_app(app)
def unregister_app(self, app):
# Unregister application during uninstallation (called at the end of package uninstall.sh)
if app not in self.conf['apps']:
return
del self.conf['apps'][app]
self.conf.save()
self.reload_wsgi_config()
def register_proxy(app):
# Setup proxy configuration and reload nginx
app_host = config.get_app(app)['host']
domain,port = config.get_host()
with open(os.path.join(NGINX_DIR, f'{app}.conf'), 'w') as f:
f.write(templates.NGINX.format(app=app, host=app_host, domain=domain, port=port))
reload_nginx()
def reload_wsgi_config(self):
# Attempt to contact running vmmgr WSGI application to reload config
def unregister_proxy(app):
# Remove proxy configuration and reload nginx
try:
os.unlink(os.path.join(NGINX_DIR, f'{app}.conf'))
reload_nginx()
except FileNotFoundError:
pass
def update_host(domain, port):
config.set_host(domain, port)
# Rebuild nginx config for the portal app. Web interface calls restart_nginx() in WSGI close handler
with open(os.path.join(NGINX_DIR, 'default.conf'), 'w') as f:
f.write(templates.NGINX_DEFAULT.format(port=port, domain_esc=domain.replace('.', '\\.')))
def reload_nginx():
subprocess.run(['/usr/sbin/nginx', '-s', 'reload'])
def restart_nginx():
subprocess.run(['/sbin/service', 'nginx', 'restart'])
def rebuild_issue():
# Compile the URLs displayed in terminal banner and rebuild the issue and motd files
domain, port = config.get_host()
issue = templates.ISSUE.format(url=net.compile_url(domain, port), ip=net.compile_url(net.get_local_ip(), port))
with open(ISSUE_FILE, 'w') as f:
f.write(issue)
with open(MOTD_FILE, 'w') as f:
f.write(issue)
def update_common_settings(email, gmaps_api_key):
# Update common configuration values
config.set_common('email', email)
config.set_common('gmaps-api-key', gmaps_api_key)
def update_password(oldpassword, newpassword):
# Update LUKS password and adminpwd for WSGI application
pwinput = f'{oldpassword}\n{newpassword}'.encode()
partition_uuid = open('/etc/crypttab').read().split()[1][5:]
partition_name = subprocess.run(['/sbin/blkid', '-U', partition_uuid], check=True, stdout=subprocess.PIPE).stdout.decode().strip()
subprocess.run(['cryptsetup', 'luksChangeKey', partition_name], input=pwinput, check=True)
# Update bcrypt-hashed password in config
config.set_adminpwd(crypto.adminpwd_hash(newpassword))
def create_selfsigned_cert():
# Disable acme.sh cronjob
os.chmod(ACME_CRON, 0o640)
# Create selfsigned certificate with wildcard alternative subject name
domain = config.get_host()[0]
crypto.create_selfsigned_cert(domain)
# Reload nginx
reload_nginx()
def request_acme_cert():
# Remove all possible conflicting certificates requested in the past
domain = config.get_host()[0]
certs = [i for i in os.listdir(ACME_DIR) if i not in ('account.conf', 'ca', 'http.header')]
for cert in certs:
if cert != domain:
subprocess.run(['/usr/bin/acme.sh', '--home', '/etc/acme.sh.d', '--remove', '-d', cert])
# Compile an acme.sh command for certificate requisition only if the certificate hasn't been requested before
if not os.path.exists(os.path.join(ACME_DIR, domain)):
cmd = ['/usr/bin/acme.sh', '--issue', '-d', domain]
for app,definition in config.get_apps():
cmd += ['-d', f'{definition["host"]}.{domain}']
cmd += ['-w', ACME_DIR]
# Request the certificate
subprocess.run(cmd, check=True)
# Otherwise just try to renew
else:
# Acme.sh returns code 2 on skipped renew
try:
requests.get(RELOAD_URL, timeout=3)
except:
pass
subprocess.run(['/usr/bin/acme.sh', '--home', '/etc/acme.sh.d', '--renew', '-d', domain], check=True)
except subprocess.CalledProcessError as e:
if e.returncode != 2:
raise
# Install the issued certificate
subprocess.run(['/usr/bin/acme.sh', '--home', '/etc/acme.sh.d', '--install-cert', '-d', domain, '--key-file', crypto.CERT_KEY_FILE, '--fullchain-file', crypto.CERT_PUB_FILE, '--reloadcmd', '/sbin/service nginx reload'], check=True)
# Enable acme.sh cronjob
os.chmod(ACME_CRON, 0o750)
def register_proxy(self, app):
# Setup proxy configuration and reload nginx
with open(os.path.join(NGINX_DIR, '{}.conf'.format(app)), 'w') as f:
f.write(templates.NGINX.format(app=app, host=self.conf['apps'][app]['host'], domain=self.conf['host']['domain'], port=self.conf['host']['port']))
self.reload_nginx()
def install_manual_cert(public_file, private_file):
# Disable acme.sh cronjob
os.chmod(ACME_CRON, 0o640)
# Copy certificate files
shutil.copyfile(public_file, crypto.CERT_PUB_FILE)
shutil.copyfile(private_file, crypto.CERT_KEY_FILE)
os.chmod(crypto.CERT_KEY_FILE, 0o600)
# Reload nginx
reload_nginx()
def unregister_proxy(self, app):
# Remove proxy configuration and reload nginx
try:
os.unlink(os.path.join(NGINX_DIR, '{}.conf'.format(app)))
self.reload_nginx()
except FileNotFoundError:
pass
def shutdown_vm():
subprocess.run(['/sbin/poweroff'])
def update_host(self, domain, port):
# Update domain and port, rebuild all configuration and restart nginx
self.domain = self.conf['host']['domain'] = domain
self.port = self.conf['host']['port'] = port
self.conf.save()
# Rebuild nginx config for the portal app. Web interface calls restart_nginx() in WSGI close handler
with open(os.path.join(NGINX_DIR, 'default.conf'), 'w') as f:
f.write(templates.NGINX_DEFAULT.format(port=self.port, domain_esc=self.domain.replace('.', '\\.')))
def reboot_vm():
subprocess.run(['/sbin/reboot'])
def reload_nginx(self):
subprocess.run(['/usr/sbin/nginx', '-s', 'reload'])
def start_app(item):
# Start the actual app service
app = item.key
if app in config.get_apps() and not is_app_started(app):
start_service(app)
def restart_nginx(self):
subprocess.run(['/sbin/service', 'nginx', 'restart'])
def start_service(service):
subprocess.run(['/sbin/service', service, 'start'], check=True)
def rebuild_issue(self):
# Compile the URLs displayed in terminal banner and rebuild the issue and motd files
issue = templates.ISSUE.format(url=net.compile_url(self.domain, self.port), ip=net.compile_url(net.get_local_ip(), self.port))
with open(ISSUE_FILE, 'w') as f:
f.write(issue)
with open(MOTD_FILE, 'w') as f:
f.write(issue)
def stop_app(item):
# Stop the actual app service
app = item.key
if app in config.get_apps() and is_app_started(app):
stop_service(app)
# Stop the app service's dependencies if they are not used by another running app
deps = get_services_deps()
for dep in get_service_deps(app):
if not any([is_app_started(d) for d in deps[dep]]):
stop_service(dep)
def update_common_settings(self, email, gmaps_api_key):
# Update common configuration values
self.conf['common']['email'] = email
self.conf['common']['gmaps-api-key'] = gmaps_api_key
self.conf.save()
def stop_service(service):
subprocess.run(['/sbin/service', service, 'stop'], check=True)
def update_password(self, oldpassword, newpassword):
# Update LUKS password and adminpwd for WSGI application
pwinput = '{}\n{}'.format(oldpassword, newpassword).encode()
partition_uuid = open('/etc/crypttab').read().split()[1][5:]
partition_name = subprocess.run(['/sbin/blkid', '-U', partition_uuid], check=True, stdout=subprocess.PIPE).stdout.decode().strip()
subprocess.run(['cryptsetup', 'luksChangeKey', partition_name], input=pwinput, check=True)
# Update bcrypt-hashed password in config
self.conf['host']['adminpwd'] = crypto.adminpwd_hash(newpassword)
# Save config to file
self.conf.save()
def update_app_visibility(app_name, visible):
# Update visibility for the app in the configuration
config.set_app(app_name, 'visible', visible)
def create_selfsigned_cert(self):
# Disable acme.sh cronjob
os.chmod(ACME_CRON, 0o640)
# Create selfsigned certificate with wildcard alternative subject name
crypto.create_selfsigned_cert(self.domain)
# Reload nginx
self.reload_nginx()
def update_app_autostart(app_name, enabled):
# Add/remove the app to OpenRC default runlevel
App(app_name).set_autostart(enabled)
def request_acme_cert(self):
# Remove all possible conflicting certificates requested in the past
certs = [i for i in os.listdir(ACME_DIR) if i not in ('account.conf', 'ca', 'http.header')]
for cert in certs:
if cert != self.domain:
subprocess.run(['/usr/bin/acme.sh', '--home', '/etc/acme.sh.d', '--remove', '-d', cert])
# Compile an acme.sh command for certificate requisition only if the certificate hasn't been requested before
if not os.path.exists(os.path.join(ACME_DIR, self.domain)):
cmd = ['/usr/bin/acme.sh', '--issue', '-d', self.domain]
for app in self.conf['apps'].copy():
cmd += ['-d', '{}.{}'.format(self.conf['apps'][app]['host'], self.domain)]
cmd += ['-w', ACME_DIR]
# Request the certificate
subprocess.run(cmd, check=True)
# Otherwise just try to renew
else:
# Acme.sh returns code 2 on skipped renew
try:
subprocess.run(['/usr/bin/acme.sh', '--home', '/etc/acme.sh.d', '--renew', '-d', self.domain], check=True)
except subprocess.CalledProcessError as e:
if e.returncode != 2:
raise
# Install the issued certificate
subprocess.run(['/usr/bin/acme.sh', '--home', '/etc/acme.sh.d', '--install-cert', '-d', self.domain, '--key-file', crypto.CERT_KEY_FILE, '--fullchain-file', crypto.CERT_PUB_FILE, '--reloadcmd', '/sbin/service nginx reload'], check=True)
# Enable acme.sh cronjob
os.chmod(ACME_CRON, 0o750)
def is_app_started(app_name):
# Assume that the main container has always the same name as app
return Container(app_name).get_status() == ContainerState.RUNNING
def install_manual_cert(self, public_file, private_file):
# Disable acme.sh cronjob
os.chmod(ACME_CRON, 0o640)
# Copy certificate files
shutil.copyfile(public_file, crypto.CERT_PUB_FILE)
shutil.copyfile(private_file, crypto.CERT_KEY_FILE)
os.chmod(crypto.CERT_KEY_FILE, 0o600)
# Reload nginx
self.reload_nginx()
def is_app_autostarted(app_name):
# Check OpenRC service enablement
return App(app_name, False).autostart
def shutdown_vm(self):
subprocess.run(['/sbin/poweroff'])
def install_app(app_name, queue):
# Main installation function. Wrapper for download, registration and install script
required_images = []
for container in repo_online.get_app(app_name)['containers'].values():
required_images.extend(repo_online.get_image(container['image'])['layers'])
local_images = repo_local.get_images()
for layer in set(required_images):
if layer not in local_images:
queue.download_image(Image(layer, False))
queue.install_app(App(app_name, False, False))
def reboot_vm(self):
subprocess.run(['/sbin/reboot'])
def uninstall_app(app_name, queue):
# Main uninstallation function. Wrapper for uninstall script, filesystem purge and unregistration
queue.uninstall_app(App(app_name, False))
def update_app(app_name, queue):
# Main update function. Wrapper for download and update script
required_images = []
for container in repo_online.get_app(app_name)['containers'].values():
required_images.extend(repo_online.get_image(container['image'])['layers'])
local_images = repo_local.get_images()
for layer in set(required_images):
if layer not in local_images:
queue.download_image(Image(layer, False))
queue.update_app(App(app_name, False))
def update_repo_settings(url, username, password):
# Include credentials in the repo URL and save to SPOC config
spoc_config = configparser.ConfigParser()
spoc_config.read('/etc/spoc/spoc.conf')
parts = urllib.parse.urlsplit(url)
netloc = f'{username}:{password}@{url}' if username or password else url
url = urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
spoc_config['repo']['url'] = ONLINE_BASE_URL = url
with open('/etc/spoc/spoc.conf', 'w') as f:
config.write(f)
def get_repo_settings():
# Parse the SPOC config repo URL and return as tuple
parts = urllib.parse.urlsplit(ONLINE_BASE_URL)
netloc = parts.netloc.split('@', 1)[1] if parts.username or parts.password else parts.netloc
url = urllib.parse.urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
return (url, parts.username, parts.password)

View File

@ -2,24 +2,18 @@
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
from werkzeug.utils import redirect
from werkzeug.wrappers import Request, Response
from jinja2 import Environment, FileSystemLoader
from cryptography.exceptions import InvalidSignature
from . import crypto
from . import net
from . import remote
from . import validator
from .actionqueue import ActionQueue
from .appmgr import AppMgr
from .config import Config
#from .pkgmgr import Stage
from .vmmgr import VMMgr
from . import config, crypto, net, remote, validator, vmmgr
from .actionqueue import ActionQueue, ActionItemType
from .wsgilang import WSGILang
from .wsgisession import WSGISession
@ -27,12 +21,8 @@ SESSION_KEY = os.urandom(26)
class WSGIApp:
def __init__(self):
self.conf = Config()
self.vmmgr = VMMgr(self.conf)
self.appmgr = AppMgr(self.conf)
self.queue = ActionQueue()
self.jinja_env = Environment(loader=FileSystemLoader('/usr/share/vmmgr/templates'), autoescape=True, lstrip_blocks=True, trim_blocks=True)
self.jinja_env.globals.update(is_app_visible=self.is_app_visible)
self.url_map = Map((
Rule('/', endpoint='portal_view'),
Rule('/login', methods=['GET'], endpoint='login_view'),
@ -72,9 +62,6 @@ class WSGIApp:
Rule('/start-vpn', endpoint='start_vpn_action'),
Rule('/stop-vpn', endpoint='stop_vpn_action'),
))
self.localhost_url_map = Map((
Rule('/reload-config', endpoint='reload_config_action'),
))
def __call__(self, environ, start_response):
return self.wsgi_app(environ, start_response)
@ -89,12 +76,7 @@ class WSGIApp:
return response(environ, start_response)
def dispatch_request(self, request):
if request.session['admin']:
url_map = self.admin_url_map
elif request.remote_addr in ('127.0.0.1', '::1'):
url_map = self.localhost_url_map
else:
url_map = self.url_map
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()
@ -113,7 +95,7 @@ class WSGIApp:
def render_template(self, template_name, request, **context):
# Enhance context
context['conf'] = self.conf
context['config'] = config
context['session'] = request.session
context['lang'] = request.session.lang
# Render template
@ -144,11 +126,11 @@ class WSGIApp:
def login_action(self, request):
password = request.form['password']
redir = request.form['redir']
if crypto.adminpwd_verify(password, self.conf['host']['adminpwd']):
if crypto.adminpwd_verify(password):
request.session['admin'] = True
return redirect('/{}'.format(redir))
request.session['msg'] = 'login:error:{}'.format(request.session.lang.bad_password())
return redirect('/login?redir={}'.format(redir)) if redir else redirect('/login')
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()
@ -156,10 +138,12 @@ class WSGIApp:
def portal_view(self, request):
# Default portal view.
host = net.compile_url(self.conf['host']['domain'], self.conf['host']['port'])[8:]
host = net.compile_url(*config.get_host(), 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)
return self.render_html('portal-user.html', request, host=host)
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.
@ -168,13 +152,17 @@ class WSGIApp:
in_ipv4 = net.get_local_ip(4)
in_ipv6 = net.get_local_ip(6)
cert_info = crypto.get_cert_info()
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 = config.get_apps()
common = config.get_common()
domain,port = 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, common=common, domain=domain, port=port)
def setup_apps_view(self, request):
# Application manager view.
repo_error = None
try:
self.appmgr.pkgmgr.fetch_online_packages() # TODO: fetch is now automatic in @property
# Populate online_repo cache or fail early when the repo can't be reached
repo_online.get_apps()
except InvalidSignature:
repo_error = request.session.lang.invalid_packages_signature()
except Unauthorized:
@ -183,80 +171,97 @@ class WSGIApp:
repo_error = request.session.lang.repo_unavailable()
table = self.render_setup_apps_table(request)
message = self.get_session_message(request)
return self.render_html('setup-apps.html', request, repo_error=repo_error, table=table, message=message)
repo_url, repo_user, _ = vmmgr.get_repo_settings()
common = config.get_common()
return self.render_html('setup-apps.html', request, repo_url=repo_url, repo_user=repo_user, repo_error=repo_error, table=table, message=message, common=common)
def render_setup_apps_table(self, request):
lang = request.session.lang
online_packages = self.appmgr.pkgmgr.online_packages
local_apps = repo_local.get_apps()
online_apps = repo_online.get_apps()
actionable_apps = sorted(set(online_apps) | set(local_apps))
pending_actions = self.queue.get_actions()
actionable_apps = sorted(set([k for k, v in online_packages.items() if 'title' in v] + list(self.conf['apps'].keys())))
app_data = {}
for app in actionable_apps:
installed = app in self.conf['packages'] and app in self.conf['apps']
title = self.conf['packages'][app]['title'] if installed else online_packages[app]['title']
visible = self.conf['apps'][app]['visible'] if installed else False
autostarted = self.appmgr.is_service_autostarted(app) if installed else False
installed = app in local_apps
title = local_apps[app]['meta']['title'] if installed else online_apps[app]['meta']['title']
try:
visible = local_apps[app]['visible']
except:
visible = False
try:
autostarted = local_apps[app]['autostart']
except:
autostarted = False
if app in pending_actions:
item = pending_actions[app]
actions = '<div class="loader"></div>'
if item.action == self.appmgr.start_app:
if not item.started:
status = '{} ({})'.format(lang.status_starting(), lang.status_queued())
elif isinstance(item.data, BaseException):
status = '<span class="error">{}</span> <a href="#" class="app-clear-status">OK</a>'.format(lang.stop_start_error())
# 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:
status = lang.status_starting()
elif item.action == self.appmgr.stop_app:
if not item.started:
status = '{} ({})'.format(lang.status_stopping(), lang.status_queued())
elif isinstance(item.data, BaseException):
status = '<span class="error">{}</span> <a href="#" class="app-clear-status">OK</a>'.format(lang.stop_start_error())
actions = None
else:
status = lang.status_stopping()
elif item.action == self.appmgr.install_app:
if not item.started:
status = '{} ({})'.format(lang.status_downloading(), lang.status_queued())
elif isinstance(item.data, InvalidSignature):
status = '<span class="error">{}</span> <a href="#" class="app-clear-status">OK</a>'.format(lang.repo_package_invalid_signature())
actions = None
elif isinstance(item.data, NotFound):
status = '<span class="error">{}</span> <a href="#" class="app-clear-status">OK</a>'.format(lang.repo_package_missing())
actions = None
elif isinstance(item.data, BaseException):
status = '<span class="error">{}</span> <a href="#" class="app-clear-status">OK</a>'.format(lang.package_manager_error())
actions = None
else:
if item.data.stage == 0: #Stage.DOWNLOAD:
status = '{} ({} %)'.format(lang.status_downloading(), item.data.percent_processed)
elif item.data.stage == 1: #Stage.UNPACK:
status = lang.status_unpacking()
elif item.data.stage == 2: #Stage.INSTALL_DEPS:
status = lang.status_installing_deps()
# Display task/subtask progress
if app_queue.action == vmmgr.start_app:
status = lang.status_starting()
elif app_queue.action == vmmgr.stop_app:
status = lang.status_stopping()
else:
status = lang.status_installing()
elif item.action == self.appmgr.uninstall_app:
if not item.started:
status = '{} ({})'.format(lang.status_uninstalling(), lang.status_queued())
elif isinstance(item.data, BaseException):
status = '<span class="error">{}</span> <a href="#" class="app-clear-status">OK</a>'.format(lang.package_manager_error())
actions = None
else:
status = lang.status_uninstalling()
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_INSTALL:
status = lang.status_installing(action_item.key)
elif action_item.type == ActionItemType.APP_UPDATE:
status = lang.status_updating(action_item.key)
elif action_item.type == ActionItemType.APP_UNINSTALL:
status = lang.status_uninstalling(action_item.key)
status = f'[{app_queue.index}/{len(app_queue.queue)}] {status}'
if action_item.show_progress:
status = f'{status} ({floor(current_action.units_done/current_action.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.uninstall_app:
status = lang.status_uninstalling('')
elif app_queue.action == vmmgr.update_app:
status = lang.status_updating('')
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 = '<a href="#" class="app-install">{}</a>'.format(lang.action_install())
actions = f'<a href="#" class="app-install">{lang.action_install()}</a>'
else:
if self.appmgr.is_service_started(app):
status = '<span class="info">{}</span>'.format(lang.status_started())
actions = '<a href="#" class="app-stop">{}</a>'.format(lang.action_stop())
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 = '<span class="error">{}</span>'.format(lang.status_stopped())
actions = '<a href="#" class="app-start">{}</a>, <a href="#" class="app-uninstall">{}</a>'.format(lang.action_start(), lang.action_uninstall())
if self.appmgr.pkgmgr.has_update(app):
actions = '{}, <a href="#" class="app-update">{}</a>'.format(actions, lang.action_update())
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>'
if parse_version(online_apps[app]['version']) > parse_version(app.version):
actions = f'{actions}, <a href="#" class="app-update">{lang.action_update()}</a>'
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)
@ -276,15 +281,16 @@ class WSGIApp:
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()})
self.vmmgr.update_host(domain, port)
url = '{}/setup-host'.format(net.compile_url(net.get_local_ip(), 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(self.vmmgr.restart_nginx)
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
domains = [self.vmmgr.domain]+['{}.{}'.format(self.conf['apps'][app]['host'], self.vmmgr.domain) for app in self.conf['apps']]
domain = config.get_host()[0]
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:
@ -304,8 +310,9 @@ class WSGIApp:
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']]
domain, port = config.get_host()
port = port if proto == 'https' else '80'
domains = [domain]+[f'{definition["host"]}.{domain}' for app,definition in config.get_apps().items()]
for domain in domains:
url = net.compile_url(domain, port, proto)
try:
@ -318,9 +325,9 @@ class WSGIApp:
def update_cert_action(self, request):
# Update certificate - either request via Let's Encrypt or manually upload files
if request.form['method'] == 'selfsigned':
self.vmmgr.create_selfsigned_cert()
vmmgr.create_selfsigned_cert()
elif request.form['method'] == 'automatic':
self.vmmgr.request_acme_cert()
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()})
@ -328,70 +335,71 @@ class WSGIApp:
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')
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()})
url = net.compile_url(self.vmmgr.domain, self.vmmgr.port)
url = net.compile_url(*config.get_host())
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
email = request.form['email']
if not validator.is_valid_email(email):
request.session['msg'] = 'common:error:{}'.format(request.session.lang.invalid_email(email))
request.session['msg'] = f'common:error:{request.session.lang.invalid_email(email)}'
else:
self.vmmgr.update_common_settings(email, request.form['gmaps-api-key'])
request.session['msg'] = 'common:info:{}'.format(request.session.lang.common_updated())
vmmgr.update_common_settings(email, request.form['gmaps-api-key'])
request.session['msg'] = f'common:info:{request.session.lang.common_updated()}'
return redirect('/setup-apps')
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'] = 'repo:error:{}'.format(request.session.lang.invalid_url(url))
request.session['msg'] = f'repo:error:{request.session.lang.invalid_url(url)}'
else:
self.appmgr.update_repo_settings(url, request.form['repousername'], request.form['repopassword'])
request.session['msg'] = 'repo:info:{}'.format(request.session.lang.repo_updated())
vmmgr.update_repo_settings(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
self.appmgr.update_app_visibility(request.form['app'], request.form['value'] == 'true')
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
self.appmgr.update_app_autostart(request.form['app'], request.form['value'] == 'true')
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
self.queue.enqueue_action(request.form['app'], action)
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_actions)
return response
def start_app_action(self, request):
# Queues application start along with its dependencies
return self.enqueue_app_action(request, self.appmgr.start_app)
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, self.appmgr.stop_app)
return self.enqueue_app_action(request, vmmgr.stop_app)
def install_app_action(self, request):
# Queues application installation
return self.enqueue_app_action(request, self.appmgr.install_app)
return self.enqueue_app_action(request, vmmgr.install_app)
def uninstall_app_action(self, request):
# Queues application uninstallation
return self.enqueue_app_action(request, self.appmgr.uninstall_app)
return self.enqueue_app_action(request, vmmgr.uninstall_app)
def update_app_action(self, request):
# Queues application update
return self.enqueue_app_action(request, self.appmgr.update_app)
return self.enqueue_app_action(request, vmmgr.update_app)
def get_app_status_action(self, request):
# Gets application and queue status
@ -410,7 +418,7 @@ class WSGIApp:
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'])
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()})
@ -418,42 +426,34 @@ class WSGIApp:
def reboot_vm_action(self, request):
# Reboots VM
response = self.render_json({'ok': request.session.lang.reboot_initiated()})
response.call_on_close(self.vmmgr.reboot_vm)
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(self.vmmgr.shutdown_vm)
response.call_on_close(vmmgr.shutdown_vm)
return response
def reload_config_action(self, request):
# Reload configuration (called by vmmgr.register_app())
self.conf.load()
return Response(status=204)
def is_app_visible(self, app):
return app in self.conf['apps'] and self.conf['apps'][app]['visible'] and self.appmgr.is_service_started(app)
def update_ssh_keys_action(self, request):
# Update authorized_keys file
remote.set_authorized_keys(request.form['ssh-keys'].replace('\r', ''))
request.session['msg'] = 'ssh:info:{}'.format(request.session.lang.ssh_keys_installed())
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'] = 'vpn:error:{}'.format(request.session.lang.invalid_ip())
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'] = 'vpn:error:{}'.format(request.session.lang.invalid_port())
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'] = 'vpn:info:{}'.format(request.session.lang.vpn_updated())
request.session['msg'] = f'vpn:info:{request.session.lang.vpn_updated()}'
return redirect('/setup-remote')
def generate_vpn_key_action(self, request):

View File

@ -40,11 +40,11 @@ class WSGILang:
'status_started': 'Spuštěna',
'status_stopping': 'Zastavuje se',
'status_stopped': 'Zastavena',
'status_downloading': 'Stahuje se',
'status_unpacking': 'Rozbaluje se',
'status_installing': 'Instaluje se',
'status_installing_deps': 'Instalují se závislosti',
'status_uninstalling': 'Odinstalovává se',
'status_downloading': 'Stahuje se {}',
'status_unpacking': 'Rozbaluje se {}',
'status_installing': 'Instaluje se {}',
'status_updating': 'Aktualizuje se {}',
'status_uninstalling': 'Odinstalovává se {}',
'status_not_installed': 'Není nainstalována',
'action_start': 'Spustit',
'action_stop': 'Zastavit',

View File

@ -1,8 +1,8 @@
{% extends 'layout.html' %}
{% block title %}Cluster NGO{% endblock %}
{% block body %}
{% if is_app_visible('sahana') %}
{% set app = conf['apps']['sahana'] %}
{% if 'sahana' in visible_apps %}
{% set app = apps['sahana'] %}
<div class="portal-box portal-box-double-width">
<h2><a href="https://sahana.{{ host }}/eden/"><img src="static/img/EDEN.png" alt="Sahana EDEN" title="Sahana EDEN">Sahana EDEN</a></h2>
<p><strong>Registr kontaktů</strong> asociací, organizací, jednotek zaměstnanců, dobrovolníků, <strong>Registr prostředků</strong>, materiálních zdrojů určených pro činnost v krizových situacích, <strong>logistika</strong> krizového zboží ve skladištích, úkrytech, <strong>organizace lidských zdrojů</strong>, diobrovolníků, <strong>mapová vizualizace</strong> pro lokalizaci a popis krizové události a <strong>mnoho dalších funkcí</strong>.</p>
@ -13,8 +13,8 @@
</div>
{% endif %}
{% if is_app_visible('sahana-demo') %}
{% set app = conf['apps']['sahana-demo'] %}
{% if 'sahana-demo' in visible_apps %}
{% set app = apps['sahana-demo'] %}
<div class="portal-box">
<h2><a href="https://sahana-demo.{{ host }}/eden/"><img src="static/img/EDEN.png" alt="Sahana EDEN DEMO" title="Sahana EDEN DEMO">Sahana EDEN DEMO</a></h2>
<p>Přístup určený k bezpečnému vyzkoušení aplikace. Zde můžete přidávat i mazat testovací data.</p>
@ -25,8 +25,8 @@
</div>
{% endif %}
{% if is_app_visible('sambro') %}
{% set app = conf['apps']['sambro'] %}
{% if 'sambro' in visible_apps %}
{% set app = apps['sambro'] %}
<div class="portal-box">
<h2><a href="https://sambro.{{ host }}/eden/"><img src="static/img/EDEN.png" alt="Sahana EDEN SAMBRO" title="Sahana EDEN SAMBRO">Sahana EDEN SAMBRO</a></h2>
<p>Samostatná instance Sahana EDEN s šablonou SAMBRO.</p>
@ -48,8 +48,8 @@
</div>
{% endif %}
{% if is_app_visible('crisiscleanup') %}
{% set app = conf['apps']['crisiscleanup'] %}
{% if 'crisiscleanup' in visible_apps %}
{% set app = apps['crisiscleanup'] %}
<div class="portal-box">
<h2><a href="https://cc.{{ host }}"><img src="static/img/Crisis_Cleanup.png" alt="Crisis Cleanup" title="Crisis Cleanup">Crisis Cleanup</a></h2>
<p><strong>Mapování krizové pomoci</strong> při odstraňování následků katastrof a koordinaci práce. Jde o majetek, ne o lidi.</p>
@ -60,8 +60,8 @@
</div>
{% endif %}
{% if is_app_visible('ckan') %}
{% set app = conf['apps']['ckan'] %}
{% if 'ckan' in visible_apps %}
{% set app = apps['ckan'] %}
<div class="portal-box">
<h2><a href="https://ckan.{{ host }}"><img src="static/img/CKAN.png" alt="CKAN" title="CKAN">CKAN</a></h2>
<p><strong>Repository</strong> management a datová analýza pro vytváření otevřených dat.</p>
@ -72,8 +72,8 @@
</div>
{% endif %}
{% if is_app_visible('opendatakit-build') %}
{% set app = conf['apps']['opendatakit-build'] %}
{% if 'opendatakit-build' in visible_apps %}
{% set app = apps['opendatakit-build'] %}
<div class="portal-box">
<h2><a href="https://odkbuild.{{ host }}"><img src="static/img/ODK.png" alt="Open Data Kit" title="Open Data Kit">ODK Build</a></h2>
<p><strong>Sběr dat s pomocí smartphone</strong>.<br>Aplikace pro návrh formulářů<br>
@ -83,8 +83,8 @@
</div>
{% endif %}
{% if is_app_visible('opendatakit') %}
{% set app = conf['apps']['opendatakit'] %}
{% if 'opendatakit' in visible_apps %}
{% set app = apps['opendatakit'] %}
<div class="portal-box">
<h2><a href="#"><img src="static/img/ODK_Collect.png" alt="Open Data Kit" title="Open Data Kit">ODK Collect</a></h2>
<p>Mobilní aplikace<br>
@ -108,8 +108,8 @@
</div>
{% endif %}
{% if is_app_visible('openmapkit') %}
{% set app = conf['apps']['openmapkit'] %}
{% if 'openmapkit' in visible_apps %}
{% set app = apps['openmapkit'] %}
<div class="portal-box">
<h2><a href="https://omk.{{ host }}"><img src="static/img/OMK.png" alt="Open Map Kit" title="Open Map Kit">OpenMapKit Server</a></h2>
<p><strong>Sběr dat s pomocí smartphone</strong>.<br>
@ -141,8 +141,8 @@
</div>
{% endif %}
{% if is_app_visible('frontlinesms') %}
{% set app = conf['apps']['frontlinesms'] %}
{% if 'frontlinesms' in visible_apps %}
{% set app = apps['frontlinesms'] %}
<div class="portal-box">
<h2><a href="https://sms.{{ host }}"><img src="static/img/FrontlineSMS.png" alt="FrontlineSMS" title="FrontlineSMS">FrontlineSMS</a></h2>
<p><strong>SMS messaging</strong> přes veřejné datové brány</p>
@ -160,8 +160,8 @@
</div>
{% endif %}
{% if is_app_visible('seeddms') %}
{% set app = conf['apps']['seeddms'] %}
{% if 'seeddms' in visible_apps %}
{% set app = apps['seeddms'] %}
<div class="portal-box">
<h2><a href="https://dms.{{ host }}"><img src="static/img/SeedDMS.png" alt="SeedDMS" title="SeedDMS">SeedDMS</a></h2>
<p><strong>Dokument management</strong> na dokumentaci a projektovou dokumentaci</p>
@ -172,8 +172,8 @@
</div>
{% endif %}
{% if is_app_visible('pandora') %}
{% set app = conf['apps']['pandora'] %}
{% if 'pandora' in visible_apps %}
{% set app = apps['pandora'] %}
<div class="portal-box">
<h2><a href="https://pandora.{{ host }}"><img src="static/img/Pandora.png" alt="Pan.do/ra" title="Pan.do/ra">Pan.do/ra</a></h2>
<p><strong>Media management</strong> na foto a video z krizové události. Tvorba metadat, komentářů, lokalizace v čase a na mapě.</p>
@ -184,8 +184,8 @@
</div>
{% endif %}
{% if is_app_visible('ushahidi') %}
{% set app = conf['apps']['ushahidi'] %}
{% if 'ushahidi' in visible_apps %}
{% set app = apps['ushahidi'] %}
<div class="portal-box">
<h2><a href="https://ush.{{ host }}"><img src="static/img/Ushahidi.png" alt="Ushahidi" title="Ushahidi">Ushahidi</a></h2>
<p>Reakce na krizovou událost. Shromažďujte zprávy od obětí a pracovníků v terénu prostřednictvím SMS, e-mailu, webu, Twitteru.</p>
@ -214,8 +214,8 @@
</div>
{% endif %}
{% if is_app_visible('kanboard') %}
{% set app = conf['apps']['kanboard'] %}
{% if 'kanboard' in visible_apps %}
{% set app = apps['kanboard'] %}
<div class="portal-box">
<h2><a href="https://kb.{{ host }}"><img src="static/img/Kanboard.png" alt="Kanboard" title="Kanboard">Kanboard</a></h2>
<p>Usnadňuje tvorbu a řízení projektů s pomocí Kanban metodiky.</p>
@ -237,8 +237,8 @@
</div>
{% endif %}
{% if is_app_visible('cts') %}
{% set app = conf['apps']['cts'] %}
{% if 'cts' in visible_apps %}
{% set app = apps['cts'] %}
<div class="portal-box">
<h2><a href="https://cts.{{ host }}"><img src="static/img/CTS.png" alt="CTS" title="CTS">CTS</a></h2>
<p>Logistika hmotné pomoci pro humanitární potřeby.</p>
@ -249,8 +249,8 @@
</div>
{% endif %}
{% if is_app_visible('gnuhealth') %}
{% set app = conf['apps']['gnuhealth'] %}
{% if 'gnuhealth' in visible_apps %}
{% set app = apps['gnuhealth'] %}
<div class="portal-box">
<h2><a href="https://gh.{{ host }}/index.html"><img src="static/img/GNU_Health.png" alt="GNU Health" title="GNU Health">GNU Health</a></h2>
<p>Zdravotní a nemocniční informační systém.</p>
@ -274,8 +274,8 @@
</div>
{% endif %}
{% if is_app_visible('sigmah') %}
{% set app = conf['apps']['sigmah'] %}
{% if 'sigmah' in visible_apps %}
{% set app = apps['sigmah'] %}
<div class="portal-box">
<h2><a href="https://sigmah.{{ host }}/sigmah/"><img src="static/img/Sigmah.png" alt="Sigmah" title="Sigmah">Sigmah</a></h2>
<p>Rozpočtování získávání finančních prostředků.</p>
@ -286,8 +286,8 @@
</div>
{% endif %}
{% if is_app_visible('motech') %}
{% set app = conf['apps']['motech'] %}
{% if 'motech' in visible_apps %}
{% set app = apps['motech'] %}
<div class="portal-box">
<h2><a href="https://motech.{{ host }}/"><img src="static/img/Motech.png" alt="Motech" title="Motech">Motech</a></h2>
<p>Integrace zdravotnických a komunikačních služeb.</p>
@ -298,8 +298,8 @@
</div>
{% endif %}
{% if is_app_visible('mifosx') %}
{% set app = conf['apps']['mifosx'] %}
{% if 'mifosx' in visible_apps %}
{% set app = apps['mifosx'] %}
<div class="portal-box">
<h2><a href="https://mifosx.{{ host }}/"><img src="static/img/MifosX.png" alt="Mifos X" title="Mifos X">Mifos X</a></h2>
<p>Nástroj na rozvojovou, humanitární pomoc a mikrofinancování.</p>
@ -321,8 +321,8 @@
</div>
{% endif %}
{% if is_app_visible('odoo') %}
{% set app = conf['apps']['odoo'] %}
{% if 'odoo' in visible_apps %}
{% set app = apps['odoo'] %}
<div class="portal-box">
<h2><a href="https://odoo.{{ host }}/"><img src="static/img/Odoo.png" alt="Odoo" title="Odoo">Odoo</a></h2>
<p>Sada aplikací pro správu organizace.</p>
@ -334,7 +334,7 @@
{% endif %}
{% if false %}
{% set app = conf['apps']['diaspora'] %}
{% set app = apps['diaspora'] %}
<div class="portal-box">
<h2><a href="#"><img src="static/img/Diaspora.png" alt="diaspora*" title="diaspora*">diaspora*</a></h2>
<p>Autonomní sociání síť s možností propojení do cizích sociálních sítí.</p>

View File

@ -1,119 +1,119 @@
{% extends 'layout.html' %}
{% block title %}Cluster NGO{% endblock %}
{% block body %}
{% if is_app_visible('sahana-demo') %}
{% if 'sahana-demo' in visible_apps %}
<div class="portal-box">
<h2><a href="https://sahana-demo.{{ host }}/eden/">Řízení humanítární činnosti</a></h2>
<p>Přístup určený k bezpečnému vyzkoušení aplikace. Zde můžete přidávat i mazat testovací data.</p>
</div>
{% endif %}
{% if is_app_visible('sambro') %}
{% if 'sambro' in visible_apps %}
<div class="portal-box">
<h2><a href="https://sambro.{{ host }}/eden/">Centrum hlášení a výstrah</a></h2>
<p>Samostatná instance s šablonou pro centrum hlášení a výstrah.</p>
</div>
{% endif %}
{% if is_app_visible('crisiscleanup') %}
{% if 'crisiscleanup' in visible_apps %}
<div class="portal-box">
<h2><a href="https://cc.{{ host }}">Mapování následků katastrof</a></h2>
<p><strong>Mapování krizové pomoci</strong> při odstraňování následků katastrof a koordinaci práce. Jde o majetek, ne o lidi.</p>
</div>
{% endif %}
{% if is_app_visible('ckan') %}
{% if 'ckan' in visible_apps %}
<div class="portal-box">
<h2><a href="https://ckan.{{ host }}">Datový sklad</a></h2>
<p><strong>Repository</strong> management a datová analýza pro vytváření otevřených dat.</p>
</div>
{% endif %}
{% if is_app_visible('opendatakit-build') %}
{% if 'opendatakit-build' in visible_apps %}
<div class="portal-box">
<h2><a href="https://odkbuild.{{ host }}">Sběr formulářových dat</a></h2>
<p><strong>Sběr dat s pomocí smartphone</strong>.<br>Aplikace pro návrh formulářů</p>
</div>
{% endif %}
{% if is_app_visible('opendatakit') %}
{% if 'opendatakit' in visible_apps %}
<div class="portal-box">
<h2><a href="https://odk.{{ host }}/">Sběr formulářových dat</a></h2>
<p><strong>Sběr dat s pomocí smartphone</strong>.</p>
</div>
{% endif %}
{% if is_app_visible('openmapkit') %}
{% if 'openmapkit' in visible_apps %}
<div class="portal-box">
<h2><a href="https://omk.{{ host }}">Sběr mapových dat</a></h2>
<p><strong>Sběr dat s pomocí smartphone</strong>.<br>
</div>
{% endif %}
{% if is_app_visible('frontlinesms') %}
{% if 'frontlinesms' in visible_apps %}
<div class="portal-box">
<h2><a href="https://sms.{{ host }}">Hromadné odesílání zpráv</a></h2>
<p><strong>SMS messaging</strong> přes veřejné datové brány</p>
</div>
{% endif %}
{% if is_app_visible('seeddms') %}
{% if 'seeddms' in visible_apps %}
<div class="portal-box">
<h2><a href="https://dms.{{ host }}">Archiv dokumentace</a></h2>
<p><strong>Dokument management</strong> na dokumentaci a projektovou dokumentaci</p>
</div>
{% endif %}
{% if is_app_visible('pandora') %}
{% if 'pandora' in visible_apps %}
<div class="portal-box">
<h2><a href="https://pandora.{{ host }}">Archiv medií</a></h2>
<p><strong>Media management</strong> na foto a video z krizové události. Tvorba metadat, komentářů, lokalizace v čase a na mapě.</p>
</div>
{% endif %}
{% if is_app_visible('ushahidi') %}
{% if 'ushahidi' in visible_apps %}
<div class="portal-box">
<h2><a href="https://ush.{{ host }}">Skupinová reakce na události</a></h2>
<p>Reakce na krizovou událost. Shromažďujte zprávy od obětí a pracovníků v terénu prostřednictvím SMS, e-mailu, webu, Twitteru.</p>
</div>
{% endif %}
{% if is_app_visible('kanboard') %}
{% if 'kanboard' in visible_apps %}
<div class="portal-box">
<h2><a href="https://kb.{{ host }}">Kanban řízení projektů</a></h2>
<p>Usnadňuje tvorbu a řízení projektů s pomocí Kanban metodiky.</p>
</div>
{% endif %}
{% if is_app_visible('gnuhealth') %}
{% if 'gnuhealth' in visible_apps %}
<div class="portal-box">
<h2><a href="https://gh.{{ host }}/index.html">Lékařské záznamy pacientů</a></h2>
<p>Zdravotní a nemocniční informační systém.</p>
</div>
{% endif %}
{% if is_app_visible('sigmah') %}
{% if 'sigmah' in visible_apps %}
<div class="portal-box">
<h2><a href="https://sigmah.{{ host }}/sigmah/">Finanční řízení sbírek</a></h2>
<p>Rozpočtování získávání finančních prostředků.</p>
</div>
{% endif %}
{% if is_app_visible('motech') %}
{% if 'motech' in visible_apps %}
<div class="portal-box">
<h2><a href="https://motech.{{ host }}/">Automatizace komunikace</a></h2>
<p>Integrace zdravotnických a komunikačních služeb.</p>
</div>
{% endif %}
{% if is_app_visible('mifosx') %}
{% if 'mifosx' in visible_apps %}
<div class="portal-box">
<h2><a href="https://mifosx.{{ host }}/">Mikrofinancování rozvojových projektů</a></h2>
<p>Nástroj na rozvojovou, humanitární pomoc a mikrofinancování.</p>
</div>
{% endif %}
{% if is_app_visible('odoo') %}
{% if 'odoo' in visible_apps %}
<div class="portal-box">
<h2><a href="https://odoo.{{ host }}/">Správa organizace</a></h2>
<p>Sada aplikací pro správu organizace.</p>

View File

@ -26,11 +26,11 @@
<table>
<tr>
<td>URL serveru:</td>
<td><input type="text" name="repourl" value="{{ conf['repo']['url'] }}"></td>
<td><input type="text" name="repourl" value="{{ repo_url }}"></td>
</tr>
<tr>
<td>Uživatelské jméno:</td>
<td><input type="text" name="repousername" value="{{ conf['repo']['user'] }}"></td>
<td><input type="text" name="repousername" value="{{ repo_user }}"></td>
</tr>
<tr>
<td>Heslo:</td>
@ -56,12 +56,12 @@
<table>
<tr>
<td>E-mail</td>
<td><input type="text" name="email" value="{{ conf['common']['email'] }}"></td>
<td><input type="text" name="email" value="{{ common['email'] }}"></td>
<td class="remark">Administrativní e-mail na který budou doručovány zprávy a upozornění z aplikací. Stejná e-mailová adresa bude také využita některými aplikacemi pro odesílání zpráv uživatelům.</td>
</tr>
<tr>
<td>Google Maps API klíč</td>
<td><input type="text" name="gmaps-api-key" value="{{ conf['common']['gmaps-api-key'] }}"></td>
<td><input type="text" name="gmaps-api-key" value="{{ common['gmaps-api-key'] }}"></td>
<td class="remark">API klíč pro službu Google Maps, která je využita některými aplikacemi.</td>
</tr>
<tr>

View File

@ -8,12 +8,12 @@
<table>
<tr>
<td>Doména</td>
<td><input type="text" name="domain" id="domain" value="{{ conf['host']['domain'] }}"></td>
<td><input type="text" name="domain" id="domain" value="{{ domain }}"></td>
<td class="remark">Plně kvalifikovaný doménový název, na kterém bude dostupný aplikační portál. Jednotlivé aplikace budou dostupné na subdoménách této domény.</td>
</tr>
<tr>
<td>Port</td>
<td><input type="text" name="port" id="port" value="{{ conf['host']['port'] }}"></td>
<td><input type="text" name="port" id="port" value="{{ port }}"></td>
<td class="remark">HTTPS port na kterém budou dostupné aplikace. Porty 22, 25, 80 a 8080 jsou vyhrazeny k jiným účelům. Výchozí HTTPS port je 443.</td>
</tr>
<tr>
@ -32,14 +32,14 @@
<p>Na jmenném serveru domény nastavené v sekci <em>HTTPS Hostitel</em> nastavte DNS záznamy typu A, případně i AAAA pro následující doménové názvy a nasměrujte je na vnější (tj. dostupnou z internetu) IP adresu tohoto virtuální stroje. Toto nastavení lze obvykle provést skrze webové rozhraní registrátora domény.</p>
<p>Vnější IPv4 {% if ex_ipv4 %}je <strong>{{ ex_ipv4 }}</strong>{% else %}nebyla zjištěna{% endif %} a IPv6 {% if ex_ipv6 %}je <strong>{{ ex_ipv6 }}</strong>{% else %}nebyla zjištěna{% endif %}.</p>
<ul>
<li>{{ conf['host']['domain'] }}</li>
<li>*.{{ conf['host']['domain'] }}</li>
<li>{{ domain }}</li>
<li>*.{{ domain }}</li>
</ul>
<p>Pokud jmenný server nepodporuje wildcard záznamy nebo pokud nemůžete či nechcete dedikovat virtuálnímu stroji všechny subdomény, nastavte místo toho záznamy pro následující doménové názvy</p>
<ul style="column-count:3">
<li>{{ conf['host']['domain'] }}</li>
{% for app in conf['apps']|sort %}
<li>{{ conf['apps'][app]['host'] }}.{{ conf['host']['domain'] }}</li>
<li>{{ domain }}</li>
{% for app in apps|sort %}
<li>{{ apps[app]['host'] }}.{{ domain }}</li>
{% endfor %}
</ul>
<input type="button" id="verify-dns" value="Ověřit nastavení DNS">
@ -52,14 +52,14 @@
<div class="setup-box">
<h2>Firewall a NAT</h2>
<p>Pokud je stávající připojení k internetu zprostředkováno routerem s NAT, na hypervizoru je nastaven firewall nebo existují jiné restrikce síťového provozu, je nutno upravit nastavení příslušných komponent, aby byl provoz na portu {{ conf['host']['port'] }} (nastaveném v sekci <em>HTTPS Hostitel</em>) z internetu korektně nasměrován na místní adresu virtuálního stroje.</p>
<p>Pokud je stávající připojení k internetu zprostředkováno routerem s NAT, na hypervizoru je nastaven firewall nebo existují jiné restrikce síťového provozu, je nutno upravit nastavení příslušných komponent, aby byl provoz na portu {{ port }} (nastaveném v sekci <em>HTTPS Hostitel</em>) z internetu korektně nasměrován na místní adresu virtuálního stroje.</p>
<p>Pokud bude využit systém automatického vyžádání a obnovy certifikátu (sekce <em>HTTPS certifikát</em>), je nutno aby byl na místní adresu virtuálního stroje nasměrován i port 80, případně byla nastavena HTTP proxy přesměrovávající doménová jména zmíněná v sekci <em>DNS záznamy</em>.</p>
<p>Místní IPv4 {% if in_ipv4 %}je <strong>{{ in_ipv4 }}</strong>{% else %}nebyla zjištěna{% endif %} a IPv6 {% if in_ipv6 %}je <strong>{{ in_ipv6 }}</strong>{% else %}nebyla zjištěna{% endif %}.</p>
<input type="button" id="verify-https" value="Ověřit nastavení portu {{ conf['host']['port'] }}">
<input type="button" id="verify-https" value="Ověřit nastavení portu {{ port }}">
<div id="https-message"></div>
<div id="https-wait" class="loader-wrap">
<div class="loader"></div>
<span>Ověřuje se nastavení firewallu a NAT pro port {{ conf['host']['port'] }}, prosím čekejte...</span>
<span>Ověřuje se nastavení firewallu a NAT pro port {{ port }}, prosím čekejte...</span>
</div>
<input type="button" id="verify-http" value="Ověřit nastavení portu 80">
<div id="http-message"></div>