#!/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)