diff --git a/usr/lib/python3.8/vmmgr/config.py b/usr/lib/python3.8/vmmgr/config.py index d9166d9..d803152 100644 --- a/usr/lib/python3.8/vmmgr/config.py +++ b/usr/lib/python3.8/vmmgr/config.py @@ -4,7 +4,7 @@ import json import os from spoc.flock import locked -from .paths import CONF_FILE, CONF_LOCK +from . import paths data = {} mtime = None @@ -12,35 +12,35 @@ mtime = None def load(): global data global mtime - file_mtime = os.stat(CONF_FILE).st_mtime + file_mtime = os.stat(paths.CONF_FILE).st_mtime if mtime != file_mtime: - with open(CONF_FILE, 'r') as f: + with open(paths.CONF_FILE, 'r') as f: data = json.load(f) mtime = file_mtime def save(): global mtime - with open(CONF_FILE, 'w') as f: + with open(paths.CONF_FILE, 'w') as f: json.dump(data, f, sort_keys=True, indent=4) - mtime = os.stat(CONF_FILE).st_mtime + mtime = os.stat(paths.CONF_FILE).st_mtime -@locked(CONF_LOCK) +@locked(paths.CONF_LOCK) def get_apps(): load() return data['apps'] -@locked(CONF_LOCK) +@locked(paths.CONF_LOCK) def get_host(): load() return data['host'] -@locked(CONF_LOCK) +@locked(paths.CONF_LOCK) def register_app(name, definition): load() data['apps'][name] = definition save() -@locked(CONF_LOCK) +@locked(paths.CONF_LOCK) def unregister_app(name): load() try: @@ -49,14 +49,17 @@ def unregister_app(name): except KeyError: pass -@locked(CONF_LOCK) +@locked(paths.CONF_LOCK) def set_host(key, value): load() data['host'][key] = value save() -@locked(CONF_LOCK) +@locked(paths.CONF_LOCK) def set_app(name, key, value): load() - data['apps'][name][key] = value - save() + try: + data['apps'][name][key] = value + save() + except KeyError: + pass diff --git a/usr/lib/python3.8/vmmgr/crypto.py b/usr/lib/python3.8/vmmgr/crypto.py index 91a797d..db06b35 100644 --- a/usr/lib/python3.8/vmmgr/crypto.py +++ b/usr/lib/python3.8/vmmgr/crypto.py @@ -9,7 +9,7 @@ from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import ec from cryptography.x509.oid import NameOID, ExtendedKeyUsageOID -from .paths import ACME_CRON, CERT_PUB_FILE, CERT_KEY_FILE +from . import paths def create_selfsigned_cert(domain): # Create selfsigned certificate with wildcard alternative subject name @@ -31,25 +31,24 @@ def create_selfsigned_cert(domain): .add_extension(x509.KeyUsage(digital_signature=True, content_commitment=False, key_encipherment=False, data_encipherment=False, key_agreement=False, key_cert_sign=False, crl_sign=False, encipher_only=False, decipher_only=False), critical=True) \ .add_extension(x509.ExtendedKeyUsage((ExtendedKeyUsageOID.SERVER_AUTH, ExtendedKeyUsageOID.CLIENT_AUTH)), critical=False) \ .sign(private_key, hashes.SHA256(), default_backend()) - with open(CERT_PUB_FILE, 'wb') as f: + with open(paths.CERT_PUB_FILE, 'wb') as f: f.write(cert.public_bytes(serialization.Encoding.PEM)) - with open(CERT_KEY_FILE, 'wb') as f: + with open(paths.CERT_KEY_FILE, 'wb') as f: f.write(private_key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())) - os.chmod(CERT_KEY_FILE, 0o600) + os.chmod(paths.CERT_KEY_FILE, 0o600) def get_cert_info(): # Gather certificate data important for setup-host - with open(CERT_PUB_FILE, 'rb') as f: + with open(paths.CERT_PUB_FILE, 'rb') as f: 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': f'{cert.not_valid_after} UTC', 'method': 'manual'} - if os.access(ACME_CRON, os.X_OK): + if os.access(paths.ACME_CRON, os.X_OK): data['method'] = 'automatic' # Naive method of inferring if the cert is selfsigned - # Good enough as reputable CAs will never have the same subject and issuer CN - # and the 'method' field is used just to populate a GUI element and not for any real cryptography + # Good enough as reputable CAs will never have the same subject and issuer CN and the 'method' field is used just to populate a GUI element and not for any real cryptography elif data['subject'] == data['issuer']: data['method'] = 'selfsigned' return data diff --git a/usr/lib/python3.8/vmmgr/net.py b/usr/lib/python3.8/vmmgr/net.py index 3e85c5e..ea80f9b 100644 --- a/usr/lib/python3.8/vmmgr/net.py +++ b/usr/lib/python3.8/vmmgr/net.py @@ -6,7 +6,7 @@ import requests import socket import subprocess -from .paths import MYIP_URL, PING_URL +from . import paths def compile_url(domain, port, proto='https'): port = '' if (proto in (None, 'https') and port == '443') or (proto == 'http' and port == '80') else f':{port}' @@ -30,7 +30,7 @@ def get_external_ip(version): allowed_gai_family = requests.packages.urllib3.util.connection.allowed_gai_family try: requests.packages.urllib3.util.connection.allowed_gai_family = lambda: family - return requests.get(MYIP_URL, timeout=5).text + return requests.get(paths.MYIP_URL, timeout=5).text except: return None finally: @@ -52,7 +52,7 @@ def resolve_ip(domain, query_type): def ping_url(url): try: - return requests.get(PING_URL, params={'url': url}, timeout=5).text == 'vm-pong' + return requests.get(paths.PING_URL, params={'url': url}, timeout=5).text == 'vm-pong' except requests.exceptions.Timeout: raise except: diff --git a/usr/lib/python3.8/vmmgr/paths.py b/usr/lib/python3.8/vmmgr/paths.py index 4866ddf..318f26d 100644 --- a/usr/lib/python3.8/vmmgr/paths.py +++ b/usr/lib/python3.8/vmmgr/paths.py @@ -1,26 +1,26 @@ # -*- coding: utf-8 -*- -# Config -CONF_FILE = '/etc/vmmgr/config.json' -CONF_LOCK = '/var/lock/vmmgr-config.lock' +# Module config +CONF_FILE = '/etc/vmmgr/config.json' +CONF_LOCK = '/var/lock/vmmgr-config.lock' # Crypto -ACME_CRON = '/etc/periodic/daily/acme-sh' -ACME_DIR = '/etc/acme.sh.d' -CERT_KEY_FILE = '/etc/ssl/services.key' -CERT_PUB_FILE = '/etc/ssl/services.pem' +ACME_CRON = '/etc/periodic/daily/acme.sh' +ACME_DIR = '/etc/acme.sh.d' +CERT_KEY_FILE = '/etc/ssl/services.key' +CERT_PUB_FILE = '/etc/ssl/services.pem' # OS -ISSUE_FILE = '/etc/issue' -MOTD_FILE = '/etc/motd' -NGINX_DIR = '/etc/nginx/conf.d' +ISSUE_FILE = '/etc/issue' +MOTD_FILE = '/etc/motd' +NGINX_DIR = '/etc/nginx/conf.d' # Remote access -AUTHORIZED_KEYS = '/root/.ssh/authorized_keys' -INTERFACES_FILE = '/etc/network/interfaces' -WG_CONF_FILE = '/etc/wireguard/wg0.conf' -WG_CONF_FILE_DISABLED = '/etc/wireguard/wg0.conf.disabled' +AUTHORIZED_KEYS = '/root/.ssh/authorized_keys' +INTERFACES_FILE = '/etc/network/interfaces' +WG_CONF_FILE = '/etc/wireguard/wg0.conf' +WG_CONF_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' +MYIP_URL = 'https://repo.spotter.cz/tools/myip.php' +PING_URL = 'https://repo.spotter.cz/tools/vm-ping.php' diff --git a/usr/lib/python3.8/vmmgr/remote.py b/usr/lib/python3.8/vmmgr/remote.py index 79a6a94..d171b6c 100644 --- a/usr/lib/python3.8/vmmgr/remote.py +++ b/usr/lib/python3.8/vmmgr/remote.py @@ -3,19 +3,19 @@ import os import subprocess -from .paths import AUTHORIZED_KEYS, INTERFACES_FILE, WG_CONF_FILE, WG_CONF_FILE_DISABLED +from . import paths def get_authorized_keys(): # Fetches content of root's authorized_files try: - with open(AUTHORIZED_KEYS) as f: + with open(paths.AUTHORIZED_KEYS) as f: return f.read() except FileNotFoundError: return '' def set_authorized_keys(keys): # Saves content of root's authorized_files - with open(AUTHORIZED_KEYS, 'w') as f: + with open(paths.AUTHORIZED_KEYS, 'w') as f: f.write(keys) # Enable or disable SSH service if keys.strip(): @@ -27,18 +27,18 @@ def set_authorized_keys(keys): def is_wireguard_running(): # Returns status of wg0 interface (inferred from existence of its config file) - return os.path.exists(WG_CONF_FILE) + return os.path.exists(paths.WG_CONF_FILE) def regenerate_wireguard_key(): # Regenerates WireGuard key pair for wg0 interface was_running = is_wireguard_running() if was_running: stop_wireguard() - privkey = subprocess.run(['wg', 'genkey'], stdout=subprocess.PIPE).stdout.decode().strip() - with open(WG_CONF_FILE_DISABLED) as f: + privkey = subprocess.run(['/usr/bin/wg', 'genkey'], stdout=subprocess.PIPE).stdout.decode().strip() + with open(paths.WG_CONF_DISABLED) as f: conf_lines = f.readlines() conf_lines[2] = f'PrivateKey = {privkey}\n' - with open(WG_CONF_FILE_DISABLED, 'w') as f: + with open(paths.WG_CONF_DISABLED, 'w') as f: f.writelines(conf_lines) if was_running: start_wireguard() @@ -48,13 +48,13 @@ def get_wireguard_conf(): # Returns dictionary with WireGuard wg0 interface VPN IP, public key, listen port and peer info result = {'running': is_wireguard_running()} # IP - with open(INTERFACES_FILE) as f: + with open(paths.INTERFACES_FILE) as f: for line in f.read().splitlines(): if '172.17.255' in line: result['ip'] = line.split('.')[-1] break # Listen port and peers - with open(WG_CONF_FILE if result['running'] else WG_CONF_FILE_DISABLED) as f: + with open(paths.WG_CONF_FILE if result['running'] else paths.WG_CONF_DISABLED) as f: conf_lines = f.read().splitlines() result['port'] = conf_lines[1].split()[2] result['peers'] = '\n'.join(conf_lines[4:]) @@ -62,8 +62,7 @@ def get_wireguard_conf(): privkey = conf_lines[2].split()[2] if privkey == 'None': privkey = regenerate_wireguard_key() - p = subprocess.Popen(['wg', 'pubkey'], stdin=subprocess.PIPE, stdout=subprocess.PIPE) - result['pubkey'] = p.communicate(privkey.encode())[0].decode().strip() + result['pubkey'] = subprocess.run(['/usr/bin/wg', 'pubkey'], input=privkey.encode(), stdout=subprocess.PIPE).stdout.decode().strip() return result def set_wireguard_conf(ip, port, peers): @@ -73,18 +72,18 @@ def set_wireguard_conf(ip, port, peers): stop_wireguard() # IP interface_lines = [] - with open(INTERFACES_FILE) as f: + with open(paths.INTERFACES_FILE) as f: for line in f.readlines(): if '172.17.255' in line: line = f' address 172.17.255.{ip}\n' interface_lines.append(line) - with open(INTERFACES_FILE, 'w') as f: + with open(paths.INTERFACES_FILE, 'w') as f: f.writelines(interface_lines) # Recreate config (listen port and peers) - with open(WG_CONF_FILE_DISABLED) as f: + with open(paths.WG_CONF_DISABLED) as f: conf_lines = f.readlines()[:4] conf_lines[1] = f'ListenPort = {port}\n' - with open(WG_CONF_FILE_DISABLED, 'w') as f: + with open(paths.WG_CONF_DISABLED, 'w') as f: f.writelines(conf_lines) f.write(peers) if was_running: @@ -93,15 +92,15 @@ def set_wireguard_conf(ip, port, peers): def start_wireguard(): # Sets up WireGuard interface try: - os.rename(WG_CONF_FILE_DISABLED, WG_CONF_FILE) + os.rename(paths.WG_CONF_DISABLED, paths.WG_CONF_FILE) except FileNotFoundError: - subprocess.run(['ifdown', 'wg0']) - subprocess.run(['ifup', 'wg0']) + subprocess.run(['/sbin/ifdown', 'wg0']) + subprocess.run(['/sbin/ifup', 'wg0']) def stop_wireguard(): # Tears down WireGuard interface - subprocess.run(['ifdown', 'wg0']) + subprocess.run(['/sbin/ifdown', 'wg0']) try: - os.rename(WG_CONF_FILE, WG_CONF_FILE_DISABLED) + os.rename(paths.WG_CONF_FILE, paths.WG_CONF_DISABLED) except FileNotFoundError: pass diff --git a/usr/lib/python3.8/vmmgr/vmmgr.py b/usr/lib/python3.8/vmmgr/vmmgr.py index dc4fe59..04ef5c6 100644 --- a/usr/lib/python3.8/vmmgr/vmmgr.py +++ b/usr/lib/python3.8/vmmgr/vmmgr.py @@ -12,8 +12,7 @@ from spoc.container import Container, ContainerState from spoc.depsolver import DepSolver from spoc.image import Image -from . import config, crypto, net, templates -from .paths import ACME_CRON, ACME_DIR, ISSUE_FILE, MOTD_FILE, NGINX_DIR +from . import config, crypto, net, paths, templates def register_app(app, host, login, password): # Register newly installed application, its subdomain and credentials (called at the end of package install.sh) @@ -35,22 +34,23 @@ def register_proxy(app): host = config.get_host() # Assume that the main container has always the same name as the app app_ip = spoc_net.get_ip(app) - with open(os.path.join(NGINX_DIR, f'{app}.conf'), 'w') as f: + with open(os.path.join(paths.NGINX_DIR, f'{app}.conf'), 'w') as f: f.write(templates.NGINX.format(app=app, host=app_host, ip=app_ip, domain=host['domain'], port=host['port'])) reload_nginx() def unregister_proxy(app): # Remove proxy configuration and reload nginx try: - os.unlink(os.path.join(NGINX_DIR, f'{app}.conf')) + os.unlink(os.path.join(paths.NGINX_DIR, f'{app}.conf')) reload_nginx() except FileNotFoundError: pass def update_host(domain, port): - config.set_host(domain, port) + config.set_host('domain', domain) + config.set_host('port', 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: + with open(os.path.join(paths.NGINX_DIR, 'default.conf'), 'w') as f: f.write(templates.NGINX_DEFAULT.format(port=port, domain_esc=domain.replace('.', '\\.'))) def reload_nginx(): @@ -65,9 +65,9 @@ def rebuild_issue(): url_host = net.compile_url(host['domain'], host['port']) url_ip = net.compile_url(net.get_local_ip(), host['port']) issue = templates.ISSUE.format(url_host=url_host, url_ip=url_ip) - with open(ISSUE_FILE, 'w') as f: + with open(paths.ISSUE_FILE, 'w') as f: f.write(issue) - with open(MOTD_FILE, 'w') as f: + with open(paths.MOTD_FILE, 'w') as f: f.write(issue) def update_password(oldpassword, newpassword): @@ -82,7 +82,7 @@ def update_password(oldpassword, newpassword): def create_selfsigned_cert(): # Disable acme.sh cronjob - os.chmod(ACME_CRON, 0o640) + os.chmod(paths.ACME_CRON, 0o640) # Create selfsigned certificate with wildcard alternative subject name domain = config.get_host()['domain'] crypto.create_selfsigned_cert(domain) @@ -92,34 +92,34 @@ def create_selfsigned_cert(): def request_acme_cert(): # Remove all possible conflicting certificates requested in the past domain = config.get_host()['domain'] - certs = [i for i in os.listdir(ACME_DIR) if i not in ('account.conf', 'ca', 'http.header')] + certs = [i for i in os.listdir(paths.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]) + subprocess.run(['/usr/bin/acme.sh', '--home', paths.ACME_DIR, '--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)): + if not os.path.exists(os.path.join(paths.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] + cmd += ['-w', paths.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', domain], check=True) + subprocess.run(['/usr/bin/acme.sh', '--home', paths.ACME_DIR, '--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) + subprocess.run(['/usr/bin/acme.sh', '--home', paths.ACME_DIR, '--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) + os.chmod(paths.ACME_CRON, 0o750) def install_manual_cert(public_file, private_file): # Disable acme.sh cronjob - os.chmod(ACME_CRON, 0o640) + os.chmod(paths.ACME_CRON, 0o640) # Copy certificate files shutil.copyfile(public_file, crypto.CERT_PUB_FILE) shutil.copyfile(private_file, crypto.CERT_KEY_FILE)