#!/usr/bin/env python3 # # gstfsd - WebVirtCloud daemon for managing VM's filesystem # # The server reads one JSON request per connection and sends exactly one JSON # reply. Each change runs in a worker process ("gstfsd --worker") that is # killed, with its guestfs appliance, when the request is not done DEADLINE # seconds after the connection was accepted. # import ipaddress import json import os import re import select import signal import socket import socketserver import subprocess import sys import threading import time PORT = int(os.environ.get("GSTFSD_PORT", 16510)) ADDRESS = os.environ.get("GSTFSD_BIND_HOST", "127.0.0.1") MAX_REQUEST = 64 * 1024 # A request, from accepting the connection to closing the appliance, must # finish within this many seconds, or it is stopped: the panel gives up 120 s # after connecting and releases the VM, which may then be started. DEADLINE = float(os.environ.get("GSTFSD_DEADLINE", 100)) MAX_DEADLINE = 100 # Time to send the reply after the deadline. REPLY_TIMEOUT = 5 # Time for a killed worker and its appliance to be gone. STOP_TIMEOUT = 5 def is_loopback(host): try: return ipaddress.ip_address(host).is_loopback except ValueError: return host == "localhost" # --- worker: one change to one guest ---------------------------------------- def set_root_password(gfs, passwd): shadow = gfs.cat('/etc/shadow') new_shadow, found = re.subn('^root:[^:]*:', lambda m: "root:" + passwd + ":", shadow, flags=re.MULTILINE) if found != 1: raise RuntimeError("/etc/shadow has %d root entries, need exactly one" % found) gfs.write('/etc/shadow', new_shadow) gfs.chmod(0o640, '/etc/shadow') def add_root_key(gfs, key): if not gfs.is_dir('/root/.ssh'): gfs.mkdir('/root/.ssh') gfs.chmod(0o700, "/root/.ssh") # Add the key; keep the ones already there. keys_path = '/root/.ssh/authorized_keys' keys = gfs.cat(keys_path) if gfs.is_file(keys_path) else '' new_key = key.strip() if new_key not in keys.splitlines(): if keys and not keys.endswith('\n'): keys += '\n' gfs.write(keys_path, keys + new_key + '\n') gfs.chmod(0o600, keys_path) # action: (function, the files it changes, where it may create files) ACTIONS = { 'password': (lambda gfs, data: set_root_password(gfs, data['passwd']), ['/etc/shadow'], None), 'publickey': (lambda gfs, data: add_root_key(gfs, data['key']), ['/root/.ssh/authorized_keys'], '/root/.ssh'), } def selinux_specfile(gfs): """The guest's SELinux file contexts, or None without an SELinux policy or with SELinux disabled (enabling it again relabels the whole guest).""" if not gfs.is_file('/etc/selinux/config'): return None config = gfs.cat('/etc/selinux/config') if re.search(r"^\s*SELINUX\s*=\s*[\"']?disabled\b", config, re.MULTILINE): return None found = re.search(r"^\s*SELINUXTYPE\s*=\s*[\"']?([\w.-]+)", config, re.MULTILINE) specfile = '/etc/selinux/%s/contexts/files/file_contexts' % (found.group(1) if found else 'targeted') return specfile if gfs.is_file(specfile) else None def holds(mountpoint, path): return mountpoint == '/' or path == mountpoint or path.startswith(mountpoint.rstrip('/') + '/') def mount_guest(gfs, paths): """Mount the guest's one operating system the way it mounts itself (LVM included), parents first. A filesystem on the way to one of paths must mount; another one that fails (a broken /data) is left out.""" roots = gfs.inspect_os() if len(roots) != 1: raise RuntimeError("found %d operating systems on the disks, need exactly one" % len(roots)) mountpoints = gfs.inspect_get_mountpoints(roots[0]) for mountpoint in sorted(mountpoints, key=len): try: gfs.mount(mountpoints[mountpoint], mountpoint) except RuntimeError: if any(holds(mountpoint, path) for path in paths): raise def change_guest(data): import guestfs action, paths, creates = ACTIONS[data['action']] gfs = guestfs.GuestFS(python_return_dict=True) launched = False try: gfs.add_domain(data['vname']) gfs.launch() launched = True mount_guest(gfs, paths) if '/etc/shadow' in paths and not gfs.is_file('/etc/shadow'): raise RuntimeError("the guest has no /etc/shadow") # A file libguestfs creates has no SELinux label, and an enforcing # guest's sshd does not read an unlabeled authorized_keys. specfile = creates and selinux_specfile(gfs) if specfile and not gfs.feature_available(['selinuxrelabel']): raise RuntimeError("the guest uses SELinux, but libguestfs on this host cannot set SELinux labels") action(gfs, data) if specfile: gfs.selinux_relabel(specfile, creates, force=True) except Exception: # the first error is the one to report; cleanup still runs if launched: try: gfs.umount_all() gfs.shutdown() except Exception: pass gfs.close() raise try: # writes reach the disk at umount and shutdown: their errors count gfs.umount_all() gfs.shutdown() finally: gfs.close() def worker(): """Read one request on stdin, change the guest, write the reply on stdout.""" try: change_guest(json.loads(sys.stdin.buffer.read())) reply = {'return': 'success'} except Exception as err: reply = {'return': 'error', 'message': str(err)} sys.stdout.write(json.dumps(reply)) return 0 # --- server ----------------------------------------------------------------- def read_request(sock, end): """The client sends one JSON object and waits for the reply: read until it parses (a long SSH key spans several reads), before end (a time.monotonic() value) and within MAX_REQUEST bytes.""" data = b"" while True: remaining = end - time.monotonic() if remaining <= 0: raise ValueError("the request was not complete within %d seconds" % DEADLINE) sock.settimeout(remaining) try: chunk = sock.recv(min(4096, MAX_REQUEST + 1 - len(data))) except socket.timeout: raise ValueError("the request was not complete within %d seconds" % DEADLINE) if not chunk: return json.loads(data) data += chunk if len(data) > MAX_REQUEST: raise ValueError("the request is longer than %d bytes" % MAX_REQUEST) try: return json.loads(data) except ValueError: continue def group_running(pgid): """Whether a process of the group still runs. A killed appliance whose parent is gone may stay a zombie until init reaps it; it holds no disk.""" for pid in os.listdir('/proc'): try: with open('/proc/%s/stat' % pid) as f: fields = f.read().rsplit(')', 1)[1].split() except (OSError, IndexError): continue # not a process, or gone meanwhile if fields[0] != 'Z' and int(fields[2]) == pgid: return True return False def stop_worker(proc): """Kill the worker with its appliance and wait until they are gone.""" try: os.killpg(proc.pid, signal.SIGKILL) except ProcessLookupError: pass # a process in uninterruptible disk I/O dies only when the I/O ends end = time.monotonic() + STOP_TIMEOUT try: proc.wait(timeout=STOP_TIMEOUT) except subprocess.TimeoutExpired: pass while time.monotonic() < end: if proc.returncode is not None and not group_running(proc.pid): return time.sleep(0.05) raise RuntimeError("the change was stopped, but its appliance is still running") def watch_client(sock, proc, done): """Kill the worker when the client leaves: the panel then no longer keeps the VM from being started. The client sends nothing more until the reply.""" while not done.is_set(): try: if select.select([sock], [], [], 0.2)[0] and not sock.recv(4096): break except OSError: break else: return try: os.killpg(proc.pid, signal.SIGKILL) except ProcessLookupError: pass def run_worker(data, end, sock): if data.get('action') not in ACTIONS: raise ValueError("unknown action %r" % data.get('action')) # Its own session, and the appliance started directly by it (not by # libvirtd): killing the worker's process group also kills the appliance. proc = subprocess.Popen( [sys.executable, os.path.abspath(__file__), '--worker'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True, env=dict(os.environ, LIBGUESTFS_BACKEND='direct'), ) done = threading.Event() watcher = threading.Thread(target=watch_client, args=(sock, proc, done), daemon=True) watcher.start() try: out, err = proc.communicate(json.dumps(data).encode(), timeout=max(0, end - time.monotonic())) except subprocess.TimeoutExpired: stop_worker(proc) raise RuntimeError("the change took longer than %d seconds and was stopped" % DEADLINE) finally: done.set() watcher.join() if proc.returncode != 0: stop_worker(proc) # a crashed or killed worker may leave its appliance lines = err.decode(errors='replace').strip().splitlines() raise RuntimeError("the change failed (exit %d): %s" % (proc.returncode, lines[-1] if lines else "no output")) return json.loads(out) class MyTCPServerHandler(socketserver.StreamRequestHandler): def handle(self): # Exactly one reply per request, success or error. end = time.monotonic() + DEADLINE try: reply = run_worker(read_request(self.request, end), end, self.request) except Exception as err: reply = {'return': 'error', 'message': str(err)} try: self.request.settimeout(REPLY_TIMEOUT) self.request.sendall(json.dumps(reply).encode()) except OSError: pass # the client is gone def serve(): # gstfsd is an unauthenticated root service that rewrites any VM's # /etc/shadow and authorized_keys, so it only listens on loopback unless # explicitly allowed. if not 0 < DEADLINE <= MAX_DEADLINE: sys.stderr.write("gstfsd: GSTFSD_DEADLINE must be above 0 and at most %d seconds\n" % MAX_DEADLINE) return 1 if not is_loopback(ADDRESS) and os.environ.get("GSTFSD_ALLOW_REMOTE") != "1": sys.stderr.write( "gstfsd: refusing to listen on %s (unauthenticated root service). " "Set GSTFSD_ALLOW_REMOTE=1 to allow it on a trusted network.\n" % ADDRESS ) return 1 class MyTCPServer(socketserver.ThreadingTCPServer): allow_reuse_address = True address_family = socket.AF_INET6 if ":" in ADDRESS else socket.AF_INET MyTCPServer((ADDRESS, PORT), MyTCPServerHandler).serve_forever() return 0 if __name__ == '__main__': sys.exit(worker() if sys.argv[1:] == ['--worker'] else serve())