Spotter-VM/basic/usr/bin/spotter-appmgr

319 lines
13 KiB
Python
Executable File

#!/usr/bin/python
# -*- coding: utf-8 -*-
import argparse
import json
import os
import subprocess
CONF_FILE = '/srv/config.json'
DISCARD_IP = '[100::1]'
ISSUE_FILE = '/etc/issue'
NGINX_DIR = '/etc/nginx/conf.d'
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 /error.html;
location /error.html {{
root /srv/portal;
}}
}}
'''
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;
}}
}}
server {{
listen [::]:{port} ssl http2 default_server ipv6only=off;
root /srv/portal;
index index.html;
location / {{
try_files $uri $uri/ =404;
}}
location /config.json {{
alias /srv/config.json;
}}
}}
'''
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
'''
class SpotterManager:
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 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 tiles-shown for the app in the configuration
self.conf['apps'][app]['tiles-shown'] = True
self.save_conf()
def hide_tiles(self, app):
# Update tiles-shown for the app in the configuration
self.conf['apps'][app]['tiles-shown'] = False
self.save_conf()
def start_app(self, app):
# Start the actual app service
subprocess.call(['/sbin/service', app, 'start'])
def stop_app(self, app):
# Stop the actual app service
subprocess.call(['/sbin/service', app, 'stop'])
# 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([self.is_app_started(d) for d in deps[dep]]):
subprocess.call(['/sbin/service', dep, 'stop'])
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.iteritems():
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 is_app_started(self, app):
# Check OpenRC service status without calling any binary
return os.path.exists(os.path.join('/run/openrc/started', app))
def enable_autostart(self, app):
# Add the app to OpenRC default runlevel
subprocess.call(['/sbin/rc-update', 'add', app])
def disable_autostart(self, app):
# Remove the app from OpenRC default runlevel
subprocess.call(['/sbin/rc-update', 'del', app])
def register_proxy(self, app):
# Rebuild nginx configuration using IP of referenced app container and reload nginx
self.update_proxy_conf(app, self.get_container_ip(app))
subprocess.call(['/sbin/service', 'nginx', 'reload'])
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
self.update_proxy_conf(app, DISCARD_IP)
subprocess.call(['/sbin/service', 'nginx', 'reload'])
def get_container_ip(self, app):
# Return an IP address of a container. If the container is not running, return address from IPv6 discard prefix instead
try:
return subprocess.check_output(['/usr/bin/docker', 'inspect', '-f', '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}', app]).strip()
except:
return DISCARD_IP
def update_domain(self, domain, port):
self.domain = self.conf['host']['domain'] = domain
self.port = self.conf['host']['port'] = port
self.save_conf()
self.rebuild_nginx()
self.rebuild_issue()
self.restart_apps()
def rebuild_nginx(self):
# 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, DISCARD_IP)
# Restart nginx to properly bind the new listen port
subprocess.call(['/sbin/service', 'nginx', 'restart'])
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 = subprocess.check_output(['/sbin/ip', 'route', 'get', '1']).split()[-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 restart_apps(self):
for app in self.conf['apps']:
# Check if a script for internal update of URL in the app exists and is executable and run it
script_path = os.path.join('/srv', app, 'update-url.sh')
if os.path.exists(script_path) and os.access(script_path, os.X_OK):
subprocess.call([script_path, '{}.{}'.format(self.conf['apps'][app]['host'], self.domain), self.port])
# If the app is currently running, restart the app service
if self.is_app_started(app):
subprocess.call(['/sbin/service', app, 'restart'])
def request_cert(self, email):
# Compile an acme.sh command for certificate requisition
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', '--accountemail', email]
# Request the certificate. If the requisition command fails, CalledProcessError will be raised
subprocess.check_output(cmd, stderr=subprocess.STDOUT)
# Install the issued certificate
subprocess.call(['/usr/bin/acme.sh', '--installcert', '-d', self.domain, '--keypath', '/etc/ssl/private/services.key', '--fullchainpath', '/etc/ssl/certs/services.pem', '--reloadcmd', 'service nginx reload'])
if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Spotter VM application manager')
subparsers = parser.add_subparsers()
parser_update_login = subparsers.add_parser('update-login', help='Updates application login')
parser_update_login.set_defaults(action='update-login')
parser_update_login.add_argument('app', help='Application name')
parser_update_login.add_argument('login', help='Administrative login')
parser_update_login.add_argument('password', help='Administrative password')
parser_show_tiles = subparsers.add_parser('show-tiles', help='Shows application tiles in Portal')
parser_show_tiles.set_defaults(action='show-tiles')
parser_show_tiles.add_argument('app', help='Application name')
parser_hide_tiles = subparsers.add_parser('hide-tiles', help='Hides application tiles in Portal')
parser_hide_tiles.set_defaults(action='hide-tiles')
parser_hide_tiles.add_argument('app', help='Application name')
parser_start_app = subparsers.add_parser('start-app', help='Start application including it\'s dependencies')
parser_start_app.set_defaults(action='start-app')
parser_start_app.add_argument('app', help='Application name')
parser_stop_app = subparsers.add_parser('stop-app', help='Stops application including it\'s dependencies if they are not used by another running application')
parser_stop_app.set_defaults(action='stop-app')
parser_stop_app.add_argument('app', help='Application name')
parser_enable_autostart = subparsers.add_parser('enable-autostart', help='Enables application autostart')
parser_enable_autostart.set_defaults(action='enable-autostart')
parser_enable_autostart.add_argument('app', help='Application name')
parser_disable_autostart = subparsers.add_parser('disable-autostart', help='Disables application autostart')
parser_disable_autostart.set_defaults(action='disable-autostart')
parser_disable_autostart.add_argument('app', help='Application name')
parser_register_proxy = subparsers.add_parser('register-proxy', help='Rebuilds nginx proxy target for an application container')
parser_register_proxy.set_defaults(action='register-proxy')
parser_register_proxy.add_argument('app', help='Application name')
parser_unregister_proxy = subparsers.add_parser('unregister-proxy', help='Removes nginx proxy target for an application container')
parser_unregister_proxy.set_defaults(action='unregister-proxy')
parser_unregister_proxy.add_argument('app', help='Application name')
parser_update_domain = subparsers.add_parser('update-domain', help='Rebuilds domain structure of VM with new domain name and new HTTPS port')
parser_update_domain.set_defaults(action='update-domain')
parser_update_domain.add_argument('domain', help='Domain name')
parser_update_domain.add_argument('port', help='HTTPS port')
parser_request_cert = subparsers.add_parser('request-cert', help='Requests and installs Let\'s Encrypt certificate for currently set domain')
parser_request_cert.set_defaults(action='request-cert')
parser_request_cert.add_argument('email', help='Email address to receive certificate notifications')
args = parser.parse_args()
sm = SpotterManager()
if args.action == 'update-login':
sm.update_login(args.app, args.login, args.password)
elif args.action == 'show-tiles':
sm.show_tiles(args.app)
elif args.action == 'hide-tiles':
sm.hide_tiles(args.app)
elif args.action == 'start-app':
sm.start_app(args.app)
elif args.action == 'stop-app':
sm.stop_app(args.app)
elif args.action == 'enable-autostart':
sm.enable_autostart(args.app)
elif args.action == 'disable-autostart':
sm.disable_autostart(args.app)
elif args.action == 'register-proxy':
sm.register_proxy(args.app)
elif args.action == 'unregister-proxy':
sm.unregister_proxy(args.app)
elif args.action == 'update-domain':
sm.update_domain(args.domain, args.port)
elif args.action == 'request-cert':
sm.request_cert(args.email)