345 lines
12 KiB
Python
345 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
|
|
from . import confupdater
|
|
from . import tools
|
|
from . import validator
|
|
|
|
VERSION = '0.0.1'
|
|
|
|
CONF_FILE = '/srv/spotter/config.json'
|
|
ISSUE_FILE = '/etc/issue'
|
|
NGINX_DIR = '/etc/nginx/conf.d'
|
|
ACME_CRON = '/etc/periodic/daily/acme-sh'
|
|
CERT_PUB_FILE = '/etc/ssl/certs/services.pem'
|
|
CERT_KEY_FILE = '/etc/ssl/private/services.key'
|
|
|
|
NGINX_TEMPLATE = '''server {{
|
|
listen [::]:{port} ssl http2;
|
|
server_name {host}.{domain};
|
|
|
|
access_log /var/log/nginx/{app}.access.log;
|
|
error_log /var/log/nginx/{app}.error.log;
|
|
|
|
location / {{
|
|
proxy_pass http://{ip}:8080;
|
|
}}
|
|
|
|
error_page 502 /502.html;
|
|
location = /502.html {{
|
|
root /srv/spotter/appmgr/templates;
|
|
}}
|
|
|
|
location = /spotter-ping {{
|
|
add_header Content-Type text/plain;
|
|
return 200 "spotter-pong";
|
|
}}
|
|
}}
|
|
'''
|
|
|
|
NGINX_DEFAULT_TEMPLATE = '''server {{
|
|
listen [::]:80 default_server ipv6only=off;
|
|
|
|
location / {{
|
|
return 301 https://$host:{port}$request_uri;
|
|
}}
|
|
|
|
location /.well-known/acme-challenge/ {{
|
|
root /etc/acme.sh.d;
|
|
}}
|
|
|
|
location = /spotter-ping {{
|
|
add_header Content-Type text/plain;
|
|
return 200 "spotter-pong";
|
|
}}
|
|
}}
|
|
|
|
server {{
|
|
listen [::]:{port} ssl http2 default_server ipv6only=off;
|
|
|
|
location / {{
|
|
proxy_pass http://127.0.0.1:8080;
|
|
}}
|
|
|
|
location /static {{
|
|
root /srv/spotter;
|
|
}}
|
|
|
|
error_page 502 /502.html;
|
|
location = /502.html {{
|
|
root /srv/spotter/appmgr/templates;
|
|
}}
|
|
|
|
location = /spotter-ping {{
|
|
add_header Content-Type text/plain;
|
|
return 200 "spotter-pong";
|
|
}}
|
|
}}
|
|
'''
|
|
|
|
ISSUE_TEMPLATE = '''
|
|
\x1b[1;32m _____ _ _ __ ____ __
|
|
/ ____| | | | | \\\\ \\\\ / / \\\\/ |
|
|
| (___ _ __ ___ | |_| |_ ___ _ _\\\\ \\\\ / /| \\\\ / |
|
|
\\\\___ \\\\| '_ \\\\ / _ \\\\| __| __/ _ \\\\ '__\\\\ \\\\/ / | |\\\\/| |
|
|
____) | |_) | (_) | |_| || __/ | \\\\ / | | | |
|
|
|_____/| .__/ \\\\___/ \\\\__|\\\\__\\\\___|_| \\\\/ |_| |_|
|
|
| |
|
|
|_|\x1b[0m
|
|
|
|
|
|
|
|
|
|
\x1b[1;33mUPOZORNĚNÍ:\x1b[0m Neoprávněný přístup k tomuto zařízení je zakázán.
|
|
Musíte mít výslovné oprávnění k přístupu nebo konfiguraci tohoto zařízení.
|
|
Neoprávněné pokusy a kroky k přístupu nebo používání tohoto systému mohou mít
|
|
za následek občanské nebo trestní sankce.
|
|
|
|
|
|
\x1b[1;33mCAUTION:\x1b[0m Unauthozired access to this device is prohibited.
|
|
You must have explicit, authorized permission to access or configure this
|
|
device. Unauthorized attempts and actions to access or use this system may
|
|
result in civil or criminal penalties.
|
|
|
|
|
|
|
|
|
|
Pro přístup k aplikacím otevřete URL \x1b[1mhttps://{host}\x1b[0m ve Vašem
|
|
internetovém prohlížeči.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
\x1b[0;30m
|
|
'''
|
|
|
|
ACME_CRON_TEMPLATE = '''#!/bin/sh
|
|
|
|
[ -x /usr/bin/acme.sh ] && /usr/bin/acme.sh --cron >/dev/null
|
|
'''
|
|
|
|
class AppMgr:
|
|
def __init__(self):
|
|
# Load JSON configuration
|
|
with open(CONF_FILE, 'r') as f:
|
|
self.conf = json.load(f)
|
|
self.domain = self.conf['host']['domain']
|
|
self.port = self.conf['host']['port']
|
|
|
|
def save_conf(self):
|
|
# Save a sorted JSON configuration object with indentation
|
|
with open(CONF_FILE, 'w') as f:
|
|
json.dump(self.conf, f, sort_keys=True, indent=4)
|
|
|
|
def update_login(self, app, login, password):
|
|
# Update login and password for an app in the configuration
|
|
if not validator.is_valid_app(app, self.conf):
|
|
raise validator.InvalidValueException('app', app)
|
|
if login is not None:
|
|
self.conf['apps'][app]['login'] = login
|
|
if password is not None:
|
|
self.conf['apps'][app]['password'] = password
|
|
self.save_conf()
|
|
|
|
def show_tiles(self, app):
|
|
# Update visibility for the app in the configuration
|
|
if not validator.is_valid_app(app, self.conf):
|
|
raise validator.InvalidValueException('app', app)
|
|
self.conf['apps'][app]['visible'] = True
|
|
self.save_conf()
|
|
|
|
def hide_tiles(self, app):
|
|
# Update visibility for the app in the configuration
|
|
if not validator.is_valid_app(app, self.conf):
|
|
raise validator.InvalidValueException('app', app)
|
|
self.conf['apps'][app]['visible'] = False
|
|
self.save_conf()
|
|
|
|
def start_app(self, app):
|
|
# Start the actual app service
|
|
if not validator.is_valid_app(app, self.conf):
|
|
raise validator.InvalidValueException('app', app)
|
|
tools.start_service(app)
|
|
|
|
def stop_app(self, app):
|
|
# Stop the actual app service
|
|
if not validator.is_valid_app(app, self.conf):
|
|
raise validator.InvalidValueException('app', app)
|
|
tools.stop_service(app)
|
|
# Stop the app service's dependencies if they are not used by another running app
|
|
deps = self.build_deps_tree()
|
|
for dep in self.get_app_deps(app):
|
|
if not any([tools.is_service_started(d) for d in deps[dep]]):
|
|
tools.stop_service(dep)
|
|
|
|
def build_deps_tree(self):
|
|
# Fisrt, build a dictionary of {app: [needs]}
|
|
needs = {}
|
|
for app in self.conf['apps']:
|
|
needs[app] = self.get_app_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_app_deps(self, app):
|
|
# Get "needs" line from init script and split it to list, skipping first two elements (docker, net)
|
|
try:
|
|
with open(os.path.join('/etc/init.d', app), 'r') as f:
|
|
return [l.split()[2:] for l in f.readlines() if l.startswith('\tneed')][0]
|
|
except:
|
|
return []
|
|
|
|
def enable_autostart(self, app):
|
|
# Add the app to OpenRC default runlevel
|
|
if not validator.is_valid_app(app, self.conf):
|
|
raise validator.InvalidValueException('app', app)
|
|
subprocess.run(['/sbin/rc-update', 'add', app])
|
|
|
|
def disable_autostart(self, app):
|
|
# Remove the app from OpenRC default runlevel
|
|
if not validator.is_valid_app(app, self.conf):
|
|
raise validator.InvalidValueException('app', app)
|
|
subprocess.run(['/sbin/rc-update', 'del', app])
|
|
|
|
def register_proxy(self, app):
|
|
# Rebuild nginx configuration using IP of referenced app container and reload nginx
|
|
if not validator.is_valid_app(app, self.conf):
|
|
raise validator.InvalidValueException('app', app)
|
|
self.update_proxy_conf(app, tools.get_container_ip(app))
|
|
tools.reload_nginx()
|
|
|
|
def update_proxy_conf(self, app, ip):
|
|
with open(os.path.join(NGINX_DIR, '{}.conf'.format(app)), 'w') as f:
|
|
f.write(NGINX_TEMPLATE.format(app=app, host=self.conf['apps'][app]['host'], ip=ip, domain=self.domain, port=self.port))
|
|
|
|
def unregister_proxy(self, app):
|
|
# Remove nginx configuration to prevent proxy mismatch when the container IP is reassigned to another container
|
|
if not validator.is_valid_app(app, self.conf):
|
|
raise validator.InvalidValueException('app', app)
|
|
self.update_proxy_conf(app, tools.NULL_IP)
|
|
tools.reload_nginx()
|
|
|
|
def update_host(self, domain, port, restart_nginx=True):
|
|
# Update domain and port and rebuild all configurtion. Defer nginx restart when updating from web interface
|
|
if not validator.is_valid_domain(domain):
|
|
raise validator.InvalidValueException('domain', domain)
|
|
if not validator.is_valid_port(port):
|
|
raise validator.InvalidValueException('port', port)
|
|
self.domain = self.conf['host']['domain'] = domain
|
|
self.port = self.conf['host']['port'] = port
|
|
self.save_conf()
|
|
self.rebuild_nginx(restart_nginx)
|
|
self.rebuild_issue()
|
|
self.update_apps_urls()
|
|
|
|
def rebuild_nginx(self, restart_nginx):
|
|
# Rebuild nginx config for the portal app
|
|
with open(os.path.join(NGINX_DIR, 'default.conf'), 'w') as f:
|
|
f.write(NGINX_DEFAULT_TEMPLATE.format(port=self.port))
|
|
# Unregister nginx proxy for apps (will be repopulated on app restart)
|
|
for app in self.conf['apps']:
|
|
self.update_proxy_conf(app, tools.NULL_IP)
|
|
# Restart nginx to properly bind the new listen port
|
|
if restart_nginx:
|
|
tools.restart_nginx()
|
|
|
|
def rebuild_issue(self):
|
|
# Compile the HTTPS host displayed in terminal banner
|
|
host = self.domain
|
|
# If the dummy host is used, take an IP address of a primary interface instead
|
|
if self.domain == 'spotter.vm':
|
|
host = tools.get_local_ipv4()
|
|
if not host:
|
|
host = tools.get_local_ipv6()
|
|
if not host:
|
|
host = '127.0.0.1'
|
|
# Show port number only when using the non-default HTTPS port
|
|
if self.port != '443':
|
|
host += ':{}'.format(self.port)
|
|
# Rebuild the terminal banner
|
|
with open(ISSUE_FILE, 'w') as f:
|
|
f.write(ISSUE_TEMPLATE.format(host=host))
|
|
|
|
def update_apps_urls(self):
|
|
# Update configuration for respective applications
|
|
host = '{}:{}'.format(self.domain, self.port) if self.port != '443' else self.domain
|
|
confupdater.update_url(host)
|
|
# Restart currently running apps in order to update config and re-register nginx proxy
|
|
for app in self.conf['apps']:
|
|
if tools.is_service_started(app):
|
|
tools.restart_service(app)
|
|
|
|
def update_common(self, email, gmaps_api_key):
|
|
# Update common configuration values
|
|
if email != None:
|
|
# Update email
|
|
if not validator.is_valid_email(email):
|
|
raise validator.InvalidValueException('email', email)
|
|
self.conf['common']['email'] = email
|
|
confupdater.update_email(email)
|
|
if gmaps_api_key != None:
|
|
# Update Google Maps API key
|
|
self.conf['common']['gmaps-api-key'] = gmaps_api_key
|
|
confupdater.update_gmaps_api_key(gmaps_api_key)
|
|
# Save config to file
|
|
self.save_conf()
|
|
# Restart currently running apps in order to update config
|
|
for app in self.conf['apps']:
|
|
if tools.is_service_started(app):
|
|
tools.restart_service(app)
|
|
|
|
def update_password(self, oldpassword, newpassword):
|
|
# Update LUKS password and adminpwd for WSGI application
|
|
tools.update_luks_password(oldpassword, newpassword)
|
|
self.conf['host']['adminpwd'] = tools.adminpwd_hash(newpassword)
|
|
# Save config to file
|
|
self.save_conf()
|
|
|
|
def request_cert(self):
|
|
# Remove all possible conflicting certificates requested in the past
|
|
certs = [i for i in os.listdir('/etc/acme.sh.d') if i not in ('account.conf', 'ca', 'http.header')]
|
|
for cert in certs:
|
|
if cert != self.domain:
|
|
subprocess.run(['/usr/bin/acme.sh', '--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('/etc/acme.sh.d', self.domain)):
|
|
cmd = ['/usr/bin/acme.sh', '--issue', '-d', self.domain]
|
|
for app in self.conf['apps']:
|
|
cmd += ['-d', '{}.{}'.format(self.conf['apps'][app]['host'], self.domain)]
|
|
cmd += ['-w', '/etc/acme.sh.d']
|
|
# 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', '--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', '--install-cert', '-d', self.domain, '--key-file', CERT_KEY_FILE, '--fullchain-file', CERT_PUB_FILE, '--reloadcmd', '/sbin/service nginx reload'], check=True)
|
|
# Install acme.sh cronjob
|
|
with open(ACME_CRON, 'w') as f:
|
|
f.write(ACME_CRON_TEMPLATE)
|
|
|
|
def install_cert(self, public_file, private_file):
|
|
# Remove acme.sh cronjob
|
|
if os.path.exists(ACME_CRON):
|
|
os.unlink(ACME_CRON)
|
|
# Copy certificate files
|
|
shutil.copyfile(public_file, CERT_PUB_FILE)
|
|
shutil.copyfile(private_file, CERT_KEY_FILE)
|
|
os.chmod(CERT_KEY_FILE, 0o640)
|
|
# Reload nginx
|
|
tools.reload_nginx()
|