#!/usr/bin/python3 # API: # - GET /api/user # - GET /api/user?id=# # - POST /api/user (id=#, action= ...) # - POST /api/user/create # # - GET /api/image # - GET /api/image?id=# # - POST /api/image (id=# actions: delete) # - POST /api/image/create (name, ...) # - PUT /api/image/create (header: X-Image-Name) # # - GET /api/vm # - GET /api/vm?id=# # - POST /api/vm (id=# actions: delete, start, reset, suspend, restore, poweroff, kill) # - POST /api/vm/create (arch, cpus, mem, disk, ...) # Global configuration: # network.mode= { manual } # network.bridge.name= { br0 } # network.bridge.mode= { subnet | # network.bridge.addr= { 172.16.1.1/24 } # network.dhcp.start= { based on bridge addr } # network.dhcp.end= { based on bridge addr } # # nic.mac.start= { 52:54:00:00:00:00 } # # iso.storage.location= { /var/lib/vmm/isos } # image.storage.location= { /var/lib/vmm/images } # # disk.storage.type= { dir } # disk.storage.location= { /var/lib/vmm/disk | vg0 } # disk.storage.name= { %u.%n } # # vm.nesting= { true } # vm.max_cpu { none } # vm.max_mem { none } # vm.max_disk { none } # # user.max_cpu { none } # user.max_mem { none } # user.max_disk { none } # # Notes: # Storage: # For storage.name has the following replacments: # %n = vm name # %u = user name # %g = group name # %U = user id # %G = group id # # Limits: # Max VM's per host: 16k (somewhat arbitrary) # Max ports available for VNC is about 60000. # At 4gb per VM, 16k VM's takes 64tb. # # TODO: # Need either daemon or suid script to manage VM IDs and resource limits. # # Daemon # ====== # name: vmmd (Virtual Machine Manager Daemon) # config: /etc/vmm/config # database: /var/lib/vmm/database.json (json, xml, pickle, ...?) # state: /var/lib/vmm/state.json (json, xml, pickle, ...?) # # VM configuration in database: # - ID # - CPU count # - Memory # - Other hardware? USB, NIC model, cdrom image, ...? import os import sys import platform import errno import getopt import pwd import hashlib import random import string import threading import subprocess import signal import time import syslog import socket import select import json import http.server import ssl import urllib.parse import urllib.request import email.parser import email.policy import sqlite3 import traceback ONE_KB = 1 << 10 ONE_MB = 1 << 20 ONE_GB = 1 << 30 ONE_TB = 1 << 40 ### Globals ### log_dir = '/var/log' log_debug = False log_verbose = 0 state_dir = '/var/lib/vmm' run_dir = '/var/run/vmm' options = None config = None uid = os.getuid() gid = os.getgid() pid = os.getpid() tmp_dir = os.environ.get('TMPDIR', '/tmp') users_table = None images_table = None vms_table = None leases = {} running = True ### Misc utilities ### def is_int(v): try: int(v) except ValueError: return False return True def to_int(v): return int(v) num_suffixes = { 'k': 1 << 10, 'K': 1 << 10, 'm': 1 << 20, 'M': 1 << 20, 'g': 1 << 30, 'G': 1 << 30, 't': 1 << 40, 'T': 1 << 40 } def is_num(v): idx = 0 while idx < len(v) and v[idx].isdigit(): idx += 1 if idx < len(v): return v[idx:] in num_suffixes return True def to_num(v): idx = 0 while idx < len(v) and v[idx].isdigit(): idx += 1 if idx < len(v): num = int(v[:idx]) suffix = v[idx:] if not suffix in num_suffixes: raise RuntimeError('Bad numeric suffix') return num * num_suffixes[suffix] return int(v) def is_float(v): try: float(v) except ValueError: return False return True def to_float(v): return float(v) bool_t_values = [ 'true', 'yes', 'on' ] bool_f_values = [ 'false', 'no', 'off' ] def is_bool(v): return v.lower() in bool_t_values + bool_f_values def to_bool(v): return v.lower() in bool_t_values def autotype(v): if not v: return '' if is_int(v): return to_int(v) if is_num(v): return to_num(v) if is_float(v): return to_float(v) if is_bool(v): return to_bool(v) return str(v) def readable_size(val, scale=1): if val is None: return 'Unknown' n = val * scale if n < 1000 * (1 << 10): d = (1 << 10) m = 'KB' elif n < 1000 * (1 << 20): d = (1 << 20) m = 'MB' elif n < 1000 * (1 << 30): d = (1 << 30) m = 'GB' else: d = (1 << 40) m = 'TB' i = int(n / d) f = int((n - (i * d)) / (d / 100)) return "%d.%02d %s" % (i, f, m) def readable_time(val): h = val / 3600 val -= h * 3600 m = val / 60 val -= m * 60 s = val if h > 0: return "%d:%02d:%02d" % (h, m, s) elif m > 0: return "%02d:%02d" % (m, s) else: return ":%02d" % (s) def parse_num(strval): idx = 0 while idx < len(strval) and (strval[idx] in '0123456789.'): idx += 1 if idx < len(strval): factors = { 'k': 1 << 10, 'kb': 1 << 10, 'm': 1 << 20, 'mb': 1 << 20, 'g': 1 << 30, 'gb': 1 << 30, 't': 1 << 40, 'tb': 1 << 40, } num = float(strval[:idx]) suffix = strval[idx:].lower().strip() if not suffix in factors: raise RuntimeError('Bad numeric suffix') return int(num * factors[suffix]) return int(strval) def mkdir_p(path, mode=None): parent = os.path.dirname(path) if parent and parent != '/': mkdir_p(parent, mode) try: if mode is None: os.mkdir(path) else: os.mkdir(path, mode) except OSError as e: if e.errno != errno.EEXIST: raise def sha256_bytes(val): hasher = hashlib.sha256() hasher.update(val) return hasher.hexdigest() def sha256_string(val): return sha256_bytes(val.encode()) def pwgen(pwlen=8): pw = '' while len(pw) < pwlen: pw += random.choice(string.ascii_letters + string.digits + '-.') return pw def file_write(pathname, buf): f = open(pathname, 'w') f.write(buf) f.close() def file_delete(pathname): try: os.unlink(pathname) except OSError as e: if e.errno != errno.ENOENT: raise def file_install(pathname, buf, mode=None): mkdir_p(os.path.dirname(pathname)) f = open(pathname, 'w') f.write(buf) f.close() if not mode is None: os.chmod(pathname, mode) def file_remove(pathname): try: os.unlink(pathname) except OSError as e: if e.errno != errno.ENOENT: raise def file_wait_exists(pathname, timeout, msg=None): if msg is None: msg = "Timeout out waiting for %s" % (pathname) elapsed = 0.0 while elapsed < timeout: if os.path.exists(pathname): return time.sleep(0.1) elapsed += 0.1 raise RuntimeError(msg) def image_pathname(root, name, ext): pathname = "%s/%s" % (root, name) if not pathname.endswith(ext): pathname += ext return pathname def image_info(pathname): physical_size = None virtual_size = None (root, ext) = os.path.splitext(pathname) format = ext[1:] try: sb = os.stat(pathname) physical_size = (sb.st_blocks * 512) / ONE_MB virtual_size = sb.st_size / ONE_MB except OSError as e: pass out = '' try: argv = ['qemu-img', 'info', '-U', pathname] (out, err) = cmd_run(argv) except RuntimeError as e: pass for line in out.rstrip('\n').split('\n'): if line.find(':') == -1: continue (k, v) = line.split(':', 1) v = v.strip() if k == 'file format': format = v if v == 'raw': f = open(pathname, 'rb') f.seek(0x8000) buf = f.read(6) f.close() if buf == b'\x01CD001': format = 'iso' if k == 'virtual size': i1 = v.find('(') + 1 i2 = v.find(' ', i1) virtual_size = int(v[i1:i2]) / ONE_MB return (physical_size, virtual_size, format) # XXX: cache results def find_in_path(name): for dir in os.environ["PATH"].split(":"): path = "%s/%s" % (dir, name) if os.path.exists(path): return path raise OSError("%s not found in %s" % (name, os.environ["PATH"])) ### Process utilities ### daemonized = False def daemonize(): global daemonized pid = os.fork() if pid > 0: os._exit(0) os.chdir('/') os.umask(0o022) nullfd = os.open('/dev/null', os.O_RDWR) os.dup2(nullfd, 0) os.dup2(nullfd, 1) os.dup2(nullfd, 2) os.close(nullfd) pid = os.fork() if pid > 0: os._exit(0) syslog.openlog(logoption=syslog.LOG_PID) daemonized = True def pid_exists(pid): if pid is None or pid < 1: return False try: os.kill(pid, 0) except OSError as e: if e.errno == errno.EPERM: return True return False return True def pid_kill(pid, nofail=None, sig=None): if nofail is None: nofail = False if sig is None: sig = signal.SIGTERM if pid <= 0: return try: os.kill(pid, sig) except OSError as e: if not nofail: raise def pidfile_read(name, path=None): if path is None: path = run_dir pathname = "%s/%s.pid" % (path, name) pid = 0 try: f = open(pathname, 'r+') line = f.readline() f.close() pid = int(line) except: pass return pid def pidfile_write(name, path=None): if path is None: path = run_dir pathname = "%s/%s.pid" % (path, name) oldpid = 0 f = open(pathname, 'a+') try: line = f.readline() oldpid = int(line) except: pass if oldpid != 0 and pid_exists(oldpid): f.close() raise RuntimeError("Cannot write pidfile, %s already running with pid %d" % (name, oldpid)) f.seek(0) f.truncate() f.write("%d\n" % (os.getpid())) f.close() def pidfile_remove(name, path=None): if path is None: path = run_dir pathname = "%s/%s.pid" % (path, name) try: os.remove(pathname) except: pass def cmd_run(args, stdin=None): logi("cmd_run: %s\n" % (args)) if not args[0].startswith('/'): args[0] = find_in_path(args[0]) child = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) if not stdin is None: child.stdin.write(stdin) (out, err) = child.communicate() rc = child.returncode if rc != 0: raise RuntimeError("Failed to run \"%s\"" % (' '.join(args))) return (out.decode(), err.decode()) def fork_child(args): logi("fork_child: %s" % (args)) cmd = args[0] if args[0].startswith('/') else find_in_path(args[0]) pid = os.fork() if pid == 0: try: os.execv(cmd, args) except BaseException as e: sys.stderr.write("os.execv raised %s\n" % (e)) sys.stderr.write("os.execv returned unexpectedly\n") os._exit(0) return pid ### Logging utilities ### def logx(level, msg): now = time.strftime("%Y-%m-%d %H:%M:%S") if not msg.endswith('\n'): msg += '\n' if log_debug: sys.stderr.write(msg) else: f = open("%s/vmmd.log" % (log_dir), "a") f.write("%s [%d] [%s] %s" % (now, os.getpid(), level, msg)) f.close() def logi(msg): logx('info', msg) def logw(msg): logx('warn', msg) def loge(msg): logx('error', msg) def logv(msg): if log_verbose > 0: logx('debug', msg) ### Signal handlers ### def sig_term(signum, frame): global running running = False def sig_chld(signum, frame): try: while True: (pid, status) = os.waitpid(0, os.WNOHANG) except: pass ### Config ### class Config: def __init__(self, pathname=None, defaults=None): if defaults: self._config = defaults else: self._config = {} if pathname: self.load(pathname) def load(self, pathname): try: f = open(pathname, 'r') except IOError as e: if e.errno == errno.ENOENT: return raise for line in f: i = line.find('#') if i != -1: line = line[:i] line = line.strip() if not line: continue append = False if line.find('+=') != -1: fields = line.split('+=', 1) append = True elif line.find('=') != -1: fields = line.split('=', 1) else: fields = [line, 'true'] k = fields[0].strip() v = autotype(fields[1].strip()) if append: if k in self._config: if is_int(self._config[k]): self._config[k] += v else: self._config[k] += " %s" % (v) else: self._config[k] = v else: self._config[k] = v def __contains__(self, k): return k in self._config def __getitem__(self, k): if not k in self._config: raise KeyError(k) return self._config[k] def get(self, k, default=None): if not k in self._config: return default return self._config[k] ### ScopedLocker ### class ScopedLocker: def __init__(self, lock): self._lock = lock self._lock.acquire() def __del__(self): self._lock.release() ### DbObject ### class DbObject: def __init__(self, row=None): self._dict = {} self._changed = set() if row: for k in row.keys(): self._dict[k] = row[k] def keys(self): return self._dict.keys() def values(self): return self._dict.values() def __contains__(self, name): return name in self._dict def __getitem__(self, name): return self._dict[name] def get(self, name, default): if name in self._dict: return self._dict[name] return default def __setitem__(self, name, val): self._changed.add(name) self._dict[name] = val class DbTable: def __init__(self, dbconn, name, sql): self._dbconn = dbconn self._name = name self._lock = threading.Lock() query = "CREATE TABLE IF NOT EXISTS %s (%s)" % (self._name, sql) self._dbconn.execute(query) def _sql_val_str(self, v): return 'NULL' if v is None else "'%s'" % (v) def empty(self): query = "SELECT COUNT(*) FROM %s" % (self._name) cursor = self._dbconn.execute(query) row = cursor.fetchone() return row[0] == 0 def select_all(self): locker = ScopedLocker(self._lock) query = "SELECT * FROM %s" % (self._name) return dbconn.execute(query) def select_where(self, wherestr): locker = ScopedLocker(self._lock) query = "SELECT * FROM %s WHERE %s" % (self._name, wherestr) res = dbconn.execute(query) if res is None: res = [] return res def delete_where(self, wherestr): locker = ScopedLocker(self._lock) query = "DELETE FROM %s WHERE %s" % (self._name, wherestr) res = dbconn.execute(query) if res is None: res = [] return res def select_one_where(self, wherestr): res = self.select_where(wherestr) row = None n = 0 for row in res: n += 1 if n != 1: raise RuntimeError("Unexpected row count") return row def select_by_id(self, id): res = self.select_where("id=%d" % (id)) if not res: return None return res.fetchone() def select_by_name(self, name): res = self.select_where("name='%s'" % (name)) if not res: return None return res.fetchone() def insert(self, obj): locker = ScopedLocker(self._lock) keystr = ','.join(obj.keys()) valstr = ','.join([self._sql_val_str(v) for v in obj.values()]) query = "INSERT INTO %s (%s) values (%s)" % (self._name, keystr, valstr) cursor = dbconn.execute(query) obj['id'] = cursor.lastrowid def update(self, obj): if not obj._changed: return locker = ScopedLocker(self._lock) query = "UPDATE %s SET " % (self._name) change_vec = [] for k in obj._changed: if k == 'id': continue change_vec.append("%s=%s" % (k, self._sql_val_str(obj._dict[k]))) query += ','.join(change_vec) query += " WHERE id=%d" % (obj._dict['id']) self._dbconn.execute(query) obj._changed = set() def delete(self, obj): locker = ScopedLocker(self._lock) query = "DELETE FROM %s WHERE id=%d" % (self._name, obj['id']) self._dbconn.execute(query) ### User ### class User(DbObject): def __init__(self, row): DbObject.__init__(self, row) @staticmethod def create(name, fullname, password, groups): return User({'name': name, 'fullname': fullname, 'pwhash': sha256_string(password), 'groups': groups}) def auth_password(self, password): pwhash = sha256_string(password) return pwhash == self['pwhash'] def in_group(self, group): return group in self['groups'].split(':') def may_access_file(self, pathname): if not self['localuid']: return False sb = os.stat(pathname) return sb.st_uid == self['localuid'] ### Image ### class Image(DbObject): def __init__(self, row): DbObject.__init__(self, row) self._copy_status = None (self._physical_size, self._virtual_size, self._fmt) = image_info(self['pathname']) self._ref = 0 @staticmethod def create_from_local(name, pathname, user, public): return Image({'name': name, 'pathname': pathname, 'owner': user['name'], 'public': public}) @staticmethod def create_from_upload(name, filename, data, user, public): if filename.endswith('.iso'): pathname = "%s/%s" % (config['iso.storage.location'], filename) else: pathname = "%s/%s" % (config['image.storage.location'], filename) if os.path.exists(pathname): raise RuntimeError("Image already exists") f = open(pathname, 'wb') f.write(data) f.close() return Image.create_from_local(name, pathname, user, public) @staticmethod def create_from_url(name, url, user, public): if url.endswith('.iso'): pathname = "%s/%s" % (config['iso.storage.location'], os.path.basename(url)) else: pathname = "%s/%s" % (config['image.storage.location'], os.path.basename(url)) if os.path.exists(pathname): raise RuntimeError("Image already exists") img = Image({'name': name, 'pathname': pathname, 'owner': user['name'], 'public': public}) acp_queue(url, pathname) return img @staticmethod def create_from_vmdisk(name, vm, user, public): filename = os.path.basename(vm['diskpath']) pathname = "%s/%s" % (config['image.storage.location'], filename) if os.path.exists(pathname): raise RuntimeError("Image already exists") img = Image({'name': name, 'pathname': pathname, 'owner': user['name'], 'public': public}) acp_queue(vm['diskpath'], img['pathname']) return img def fmt(self): return self._fmt def physical_size(self): try: sb = os.stat(self['pathname']) self._physical_size = (sb.st_blocks * 512) / ONE_MB except OSError as e: pass return self._physical_size def virtual_size(self): return self._virtual_size def type(self): return 'iso' if self._fmt == 'iso' else 'disk' def incref(self): self._ref += 1 def decref(self): assert self._ref > 0 self._ref -= 1 ### VirtualMachine ### class VirtualMachine(DbObject): def __init__(self, row): DbObject.__init__(self, row) self._lock = threading.Lock() if self.get('macaddr', None) is None: found = False while not found: b4 = int(random.random() * 256) b5 = int(random.random() * 256) b6 = int(random.random() * 256) macaddr = "52:54:00:%02x:%02x:%02x" % (b4, b5, b6) cursor = vms_table.select_where("macaddr='%s'" % (macaddr)) row = cursor.fetchone() if not row: found = True self['macaddr'] = macaddr if self.get('vncpass', None) is None: self['vncpass'] = pwgen(8) if self.get('uuid', None) is None: f1 = "%08x" % (int(random.random() * (1 << 32))) f2 = "%04x" % (int(random.random() * (1 << 16))) f3 = "%04x" % (int(random.random() * (1 << 16))) f4 = "%04x" % (int(random.random() * (1 << 16))) f5 = "%012x" % (int(random.random() * (1 << 48))) self['uuid'] = "%s-%s-%s-%s-%s" % (f1, f2, f3, f4, f5) (self._disk_psize, self._disk_vsize, self._disk_fmt) = image_info(self['diskpath']) self._copy_status = None @staticmethod def pathname_for_disk(username, vmname, ext): return "%s/%s.%s%s" % (config['disk.storage.location'], username, vmname, ext) @staticmethod def create_new(name, owner, arch, cpus, mem, disk_size): # XXX: deal with LVM diskpath = VirtualMachine.pathname_for_disk(owner['name'], name, '.qcow2') if os.path.exists(diskpath): raise RuntimeError("Disk already exists") argv = ['qemu-img', 'create', '-f', 'qcow2', '-o', 'preallocation=metadata', diskpath, "%dM" % (disk_size)] cmd_run(argv) return VirtualMachine({'name': name, 'owner': owner['name'], 'arch': arch, 'cpus': cpus, 'mem': mem, 'diskpath': diskpath}) @staticmethod def create_from_image(name, owner, arch, cpus, mem, image_id): # XXX: deal with LVM img = disk_images_table.select_by_id(image_id) (root, ext) = os.path.splitext(img['pathname']) diskpath = VirtualMachine.pathname_for_disk(owner['name'], name, ext) if os.path.exists(diskpath): raise RuntimeError("Disk already exists") if ext == '.qcow2': argv = ['qemu-img', 'create', '-f', 'qcow2', '-b', img['pathname'], diskpath] cmd_run(argv) else: acp_queue(img['pathname'], diskpath) return VirtualMachine({'name': name, 'owner': owner['name'], 'arch': arch, 'cpus': cpus, 'mem': mem, 'diskpath': diskpath}) @staticmethod def create_from_local(name, owner, arch, cpus, mem, pathname): return VirtualMachine({'name': name, 'owner': owner['name'], 'arch': arch, 'cpus': cpus, 'mem': mem, 'diskpath': pathname}) @staticmethod def create_from_upload(name, owner, arch, cpus, mem, filename, data): diskpath = "%s/%s" % (config['disk.storage.location'], filename) if os.path.exists(diskpath): raise RuntimeError("Disk already exists") f = open(diskpath, 'wb') f.write(data) f.close() return VirtualMachine.create_from_local(name, owner, arch, cpus, mem, diskpath) @staticmethod def create_from_url(name, owner, arch, cpus, mem, image_url): # XXX: deal with LVM (root, ext) = os.path.splitext(image_url) diskpath = VirtualMachine.pathname_for_disk(owner.name(), name, ext) if os.path.exists(diskpath): raise RuntimeError("Disk already exists") vm = VirtualMachine.create_from_local(name, owner, arch, cpus, mem, diskpath) acp_queue(image_url, diskpath) return vm def _qemu_pidfile(self): return "%s/%04x/qemu.pid" % (run_dir, self['id']) def _get_iso_pathname(self): pathname = None if self.running(): lines = self._run_monitor_command('info block ide1-cd0') for line in lines: if line.startswith('ide1-cd0'): fields = line.split(':', 1) pathname = fields[1].strip().split(' ')[0] if pathname.startswith('['): pathname = None return pathname # Return list of snapshots as (id, tag). Disk must not be in use. def _snapshot_list(self): snapshots = [] if self['diskpath'].endswith('.qcow2'): argv = ['qemu-img', 'snapshot', '-l', self['diskpath']] (out, err) = cmd_run(argv) for line in out.rstrip('\n').split('\n'): fields = line.split() if len(fields) < 3: continue if fields[0] == 'ID': continue snapshots.append((fields[0], fields[1])) return snapshots def _has_snapshot_tag(self, tag): snapshots = self._snapshot_list() for item in snapshots: if item[1] == tag: return True return False def disk_exists(self): return not self._disk_vsize is None def disk_physical_size(self): try: sb = os.stat(self['diskpath']) self._disk_psize = (sb.st_blocks * 512) / ONE_MB except OSError as e: pass return self._disk_psize def disk_virtual_size(self): return self._disk_vsize def pid(self): try: f = open(self._qemu_pidfile(), 'r') buf = f.read() f.close() pid = int(buf.strip()) if os.path.isdir("/proc/%d" % (pid)): return pid return None except BaseException as e: return None def running(self): return not self.pid() is None def state(self): if self.running(): return 'running' return 'stopped' def ipv4addr(self): return leases.get(self['macaddr'], None) def ipv6addr(self, val=None): return None def start(self, **kwargs): if not self.disk_exists(): raise RuntimeError("Cannot start without disk") if not acp_progress(self['diskpath']) is None: raise RuntimeError("Cannot start while copying") force_readonly = self['diskpath'].endswith('.vmdk') readonly = force_readonly or kwargs.get('readonly', False) if force_readonly and not readonly: raise RuntimeError("VMDK disks must be read-only") resuming = (not readonly) and self._has_snapshot_tag('vmm-suspend') vm_run_dir = "%s/%04x" % (run_dir, self['id']) mkdir_p(vm_run_dir) has_usb = False has_pci = False if self['arch'] == 'x86_64': prog = 'qemu-system-x86_64' machine_arg = 'pc' if platform.machine() == 'x86_64': machine_arg += ',accel=kvm' cpu_arg = 'host' if config['vm.nesting'] else 'qemu64' has_usb = True has_pci = True elif self['arch'] == 'arm64': prog = 'qemu-system-aarch64' machine_arg = 'virt' cpu_arg = 'cortex-a53' has_pci = True elif self['arch'] == 'rpi3': prog = 'qemu-system-aarch64' machine_arg = 'raspi3' cpu_arg = 'cortex-a53' else: raise RuntimeError('Unknown arch') argv = [prog] argv.extend(['-daemonize', '-pidfile', self._qemu_pidfile()]) argv.extend(['-name', self['name']]) argv.extend(['-machine', machine_arg, '-cpu', cpu_arg]) ethdev = 'virtio-net' if self['ostype'] == 'linux' else 'e1000' blkopt = '' if self['arch'] == 'x86_64': blkopt += ',if=virtio' if self['ostype'] == 'linux' else ',if=ide' if self['diskpath'].endswith('.img'): blkopt += ',format=raw' if readonly: blkopt += ',snapshot=on' argv.extend(['-smp', str(self['cpus']), '-m', "%dM" % self['mem'], '-monitor', "unix:%s/monitor,server,nowait" % (vm_run_dir), '-serial', "unix:%s/serial,server,nowait" % (vm_run_dir), '-vnc', ":%d,password=on" % (self['id']), '-smbios', "type=1,uuid=%s" % (self['uuid']), '-drive', "file=%s%s" % (self['diskpath'], blkopt)]) if has_pci: argv.extend(['-netdev', "bridge,br=%s,id=net1" % (config['network.bridge.name']), '-device', "%s,netdev=net1,mac=%s" % (ethdev, self['macaddr'])]) if has_usb: argv.extend(['-usb', '-device', 'usb-tablet']) if self['isopath']: # XXX? -drive media=cdrom,file=%s argv.extend(['-cdrom', self['isopath'], '-boot', 'd']) if resuming: argv.extend(['-loadvm', 'vmm-suspend']) try: os.unlink(self._qemu_pidfile()) except OSError as e: pass fork_child(argv) file_wait_exists(self._qemu_pidfile(), 2.0) res = self._run_monitor_command('info status', 30.0) if not res: loge('Failed to communicate with monitor') if resuming: self._run_monitor_command('delvm vmm-suspend') if self['vncpass']: self._run_monitor_command("change vnc password %s" % (self['vncpass'])) def _drain_monitor_socket(self, sock): buf = '' while not buf.endswith('\n(qemu) '): buf += sock.recv(4096).decode() lines = buf.rstrip('\n').replace('\r', '').split('\n') return lines def _run_monitor_command(self, cmd, timeout=None): if timeout is None: timeout = 1.0 locker = ScopedLocker(self._lock) vm_run_dir = "%s/%04x" % (run_dir, self['id']) pathname = "%s/monitor" % (vm_run_dir) file_wait_exists(pathname, 1.0) sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.settimeout(timeout) res = [] try: sock.connect(pathname) self._drain_monitor_socket(sock) line = cmd if not line.endswith('\n'): line += '\n' sock.send(line.encode()) res = self._drain_monitor_socket(sock) except BaseException as e: print("Exception running monitor command: %s" % (e)) return res def iso_eject(self): if self.running(): self._run_monitor_command('eject ide1-cd0') self['isopath'] = None def iso_insert(self, isopath): self.iso_eject() self['isopath'] = isopath if self.running(): self._run_monitor_command("change ide1-cd0 %s" % (self['isopath'])) def reset(self): self._run_monitor_command('system_reset') def suspend(self): self._run_monitor_command('savevm vmm-suspend', 15.0) self.kill() def poweroff(self): self._run_monitor_command('system_powerdown') def kill(self): pid = self.pid() if pid: os.kill(pid, signal.SIGTERM) tries = 0 while tries < 10 and not self.pid() is None: tries += 1 time.sleep(0.1) ### CliClientConnectionHandler and listener ### class CliClientConnectionHandler(threading.Thread): def __init__(self, sock, secure): # XXX: This should not be instance data self._dispatch_table = { 'login': self.cmd_login, 'serial': self.cmd_serial, 'monitor': self.cmd_monitor, 'help': self.cmd_help } threading.Thread.__init__(self) self._sock = sock self._secure = secure self._user = None # Override def run(self): buf = '' try: while True: self._sock.send('vmm> '.encode()) while buf.find('\n') == -1: data = self._sock.recv(4096) if not data: return for ch in data.decode(): if ch == '\x7f': if len(buf) > 0: buf = buf[:-1] self._sock.send(b'\x08 \x08') continue if ch == '\r' or ch == '\n': buf += '\n' self._sock.send(b'\r\n') continue self._sock.send(ch.encode()) buf += ch idx = buf.find('\n') while idx != -1: line = buf[:idx] buf = buf[idx+1:] idx = buf.find('\n') fields = line.split() if not fields: continue cmd = fields[0] args = fields[1:] if cmd == 'q' or cmd == 'quit': raise BrokenPipeError('Client quit') if not self._user and not cmd in ['login', 'help']: self.send_msg('-Unauthorized') continue if not cmd in self._dispatch_table: self.send_msg('-Unknown command') continue msg = self._dispatch_table[cmd](args) self.send_msg(msg) except BaseException as e: loge("Exception handling client: %s" % (e)) def send_msg(self, msg): buf = "%s\r\n" % (msg) self._sock.send(buf.encode()) def cmd_login(self, args): if len(args) != 2: return '-Invalid usage' if is_int(args[0]): row = users_table.select_by_id(int(args[0])) else: row = users_table.select_by_name(args[0]) user = User(row) if len(args[1]) == 256/8*2: if user.pwhash() != args[1]: return '-Unauthorized' else: if not self._secure or not user.auth_password(args[1]): return '-Unauthorized' self._user = user return '+ok' def _cmd_passthru(self, name, args): if len(args) != 1: return '-Invalid usage' if is_int(args[0]): row = vms_table.select_by_id(int(args[0])) else: row = vms_table.select_by_name(args[0]) if row['owner'] != self._user['name'] and not self._user.in_group('admin'): return '-Unauthorized' self.send_msg("Connecting to %s ...\n" % (row['name'])) vm_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) vm_sock.connect("%s/%04x/%s" % (run_dir, row['id'], name)) self.send_msg("Escape char is '^['\n") try: while True: (rfds, wfds, efds) = select.select([self._sock, vm_sock], [], []) if vm_sock in rfds: buf = vm_sock.recv(4096) self._sock.send(buf) if self._sock in rfds: buf = self._sock.recv(4096) idx = buf.find(b'\x1b') if idx != -1: vm_sock.send(buf[:idx]) break vm_sock.send(buf) except BrokenPipeError as e: self.send_msg("Connection lost") return '+ok' def cmd_serial(self, args): return self._cmd_passthru('serial', args) def cmd_monitor(self, args): return self._cmd_passthru('monitor', args) def cmd_help(self, args): self.send_msg('login ') self.send_msg('serial ') self.send_msg('monitor ') self.send_msg('help') return '+ok' def raw_cli_listener(): listen_addr = (config['cli.listen.address'], config['cli.listen.port']) raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0) raw_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) raw_sock.bind(listen_addr) server_sock = raw_sock server_sock.listen() while running: try: conn, addr = server_sock.accept() thread = CliClientConnectionHandler(conn, False) thread.start() except BaseException as e: loge("Exception in raw_cli_listener: %s" % (e)) def ssl_cli_listener(): listen_addr = (config['cli.listen.ssladdress'], config['cli.listen.sslport']) raw_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0) raw_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) raw_sock.bind(listen_addr) ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_SSLv23) ssl_ctx.options = ssl.OP_NO_TLSv1_3 ssl_ctx.check_hostname = False ssl_ctx.load_cert_chain(config['ssl.certfile']) server_sock = ssl_ctx.wrap_socket(raw_sock, server_side=True) server_sock.listen() while running: try: conn, addr = server_sock.accept() thread = CliClientConnectionHandler(conn, True) thread.start() except BaseException as e: loge("Exception in ssl_cli_listener: %s" % (e)) ### HttpClientRequestHandler and http_listener ### class HttpClientRequestHandler(http.server.BaseHTTPRequestHandler): def __init__(self, request, client_address, server): self._dispatch_table = { '/api/v1/user': self._api_v1_user, '/api/v1/user/create': self._api_v1_user_create, '/api/v1/image': self._api_v1_image, '/api/v1/image/create': self._api_v1_image_create, '/api/v1/vm': self._api_v1_vm, '/api/v1/vm/create': self._api_v1_vm_create, # XXX: Ensure aligned with API # XXX: Add image manipulation (create/upload, delete) '/ui': self.ui_overview, '/ui/login': self.ui_login, '/ui/logout': self.ui_logout, '/ui/image': self.ui_image, '/ui/image/create': self.ui_image_create, '/ui/user': self.ui_user, '/ui/user/create': self.ui_user_create, '/ui/vm': self.ui_vm, '/ui/vm/create': self.ui_vm_create } http.server.BaseHTTPRequestHandler.__init__(self, request, client_address, server) # Override def version_string(self): return 'VirtualMachineManager/1.0' def _send_response(self, code, headers, content): encoded_content = content.encode() if content else None self.send_response(code) if headers: for k,v in headers.items(): self.send_header(k, v) if content: if content.startswith('<'): self.send_header('Content-Type', 'text/html') else: self.send_header('Content-Type', 'application/json') self.send_header('Content-Length', len(encoded_content)) self.end_headers() if encoded_content: self.wfile.write(encoded_content) def _api_v1_user(self, user, args): is_admin = user.in_group('admin') args_id = None if 'id' in args: args_id = int(args['id'][0]) if args_id != user['id'] and not is_admin: self._send_response(403, None, json.dumps({'result': 403})) if 'action' in args: changed = False if args['action'][0] == 'edit': if 'fullname' in args: user.fullname(args['fullname'][0]) changed = True if 'password' in args: user.password(args['password'][0]) changed = True if 'email' in args: user.email(args['email'][0]) if args['action'][0] == 'delete': if not user.is_admin: self._send_response(403, None, json.dumps({'result': 403})) return user_db.remove(user) if changed: user_db.save() r = json.dumps({ 'result': 200, 'id': user.oid(), 'name': user.name(), 'fullname': user.fullname(), 'groups': user.groups(), 'email': user.email(), 'timezone': user.timezone() }) self._send_response(200, None, r) def _api_v1_user_create(self, user, args): self._send_response(201, None, None) pass def _api_v1_image(self, user, args): pass def _api_v1_image_create(self, user, args): pass def _api_v1_vm(self, user, args): pass def _api_v1_vm_create(self, user, args): pass def _html_head(self, user=None): r = '' r += '\n' r += ' \n' r += ' Virtual Machine Manager\n' r += ' \n' r += ' \n' r += ' \n' r += ' \n' r += ' \n' r += ' \n' r += '
 \n' r += " \"logo\"\n" % (config['ui.imagepath']) if user: r += " %s
logout\n" % (user['fullname']) else: r += '
 \n' r += '
 \n' r += '

Virtual Machine Manager

\n' r += '
 \n' r += '
\n' r += '
\n' if user: r += ' \n' r += ' \n' r += '
\n' if user.in_group('admin'): r += '

Admin

\n' r += '
\n' r += ' Overview
\n' r += '
\n' r += ' Virtual Machines
\n' r += '
\n' r += ' Disk Images
\n' r += '
\n' r += ' ISO Images
\n' r += '
\n' r += ' Settings
\n' r += '
\n' r += '
\n' return r def _html_foot(self, user=None): r = '' if user: r += '
\n' r += ' \n' r += '\n' return r def _arch_select(self, arch=None): r = '' r += '' return r def _ostype_select(self, ostype=None): r = '' r += '' return r def _iso_image_select(self): r = '' r += '' return r def _disk_image_select(self, img_id=None): r = '' r += '' return r def ui_login(self, user, args): msg = None if 'username' in args and 'password' in args: username = args['username'][0] row = users_table.select_by_name(username) if row: user = User(row) password = args['password'][0] if user.auth_password(args['password'][0]): now = int(time.time()) hash = sha256_string("%s%s%s" % (user['name'], user['pwhash'], now)) expire = now + config['ui.session.duration'] session = {'hash': hash, 'user_id': user['id'], 'expire': expire} sessions_table.insert(session) cookie = "session=%s; path=/ui" % session['hash'] self._send_response(302, {'Set-Cookie': cookie, 'Location': '/ui'}, None) return msg = 'Login failed' r = self._html_head() r += '
\n' r += '
\n' r += ' \n' r += ' \n' r += ' \n' r += ' \n' r += ' \n' r += ' \n' r += ' \n' r += ' \n' r += '
 
Login
 
Username
Password
\n' r += '
\n' r += '
\n' if msg: r += "

%s

\n" % (msg) r += '
\n' r += self._html_foot() self._send_response(200, None, r) def ui_logout(self, user, args): cookie = 'session=none; path=/ui' self._send_response(302, {'Set-Cookie': cookie, 'Location': '/ui/login'}, None) def ui_overview(self, user, args): total = { 'cpus': 0, 'mem': 0, 'phys_disk': 0, 'virt_disk': 0 } active = { 'cpus': 0, 'mem': 0, 'phys_disk': 0, 'virt_disk': 0 } for row in vms_table.select_all(): if row['owner'] != user['name'] and not user.in_group('admin'): continue vm = VirtualMachine(row) total['cpus'] += vm['cpus'] total['mem'] += vm['mem'] if vm.disk_exists(): total['phys_disk'] += vm.disk_physical_size() total['virt_disk'] += vm.disk_virtual_size() if vm.running(): active['cpus'] += vm['cpus'] active['mem'] += vm['mem'] if vm.disk_exists(): active['phys_disk'] += vm.disk_physical_size() active['virt_disk'] += vm.disk_virtual_size() r = self._html_head(user) r += '

Overview

\n' r += ' \n' r += '
 ActiveTotal\n' r += "
CPUs%d%d\n" % (active['cpus'], total['cpus']) r += "
Memory%s%s\n" % (readable_size(active['mem'], ONE_MB), readable_size(total['mem'], ONE_MB)) r += "
Physical Disk%s%s\n" % (readable_size(active['phys_disk'], ONE_MB), readable_size(total['phys_disk'], ONE_MB)) r += "
Virtual Disk%s%s\n" % (readable_size(active['virt_disk'], ONE_MB), readable_size(total['virt_disk'], ONE_MB)) r += '
\n' r += self._html_foot(user) self._send_response(200, None, r) def ui_image(self, user, args): # XXX: auth r = self._html_head(user) err = None msg = None is_admin = user.in_group('admin') img_type = args['type'][0] if 'type' in args else 'disk' table = iso_images_table if img_type == 'iso' else disk_images_table if 'id' in args: args_id = int(args['id'][0]) row = table.select_by_id(args_id) img = Image(row) if img['owner'] != user['name'] and not img['public'] and not is_admin: r += '

Access denied

\n' r += self._html_foot(user) self._send_response(403, None, r) return else: args_id = None img = None # XXX: handle delete better, like user does if img: editable = img['owner'] == user['name'] or is_admin edit_mode = False if 'action' in args: if not editable: err = 'Permission denied' args['action'][0] = None if args['action'][0] == 'Edit': edit_mode = True if args['action'][0] == 'Save': if 'name' in args: img['name'] = args['name'][0] img['public'] = 1 if 'public' in args else 0 table.update(img) msg = 'Settings saved' if args['action'][0] == 'Delete': table.delete(img) path = "/ui/image?type=%s" % (img.type()) self._send_response(302, {'Location': path}, None) return r += "

%s

\n" % (img['name']) if msg: r += "

%s

\n" % (msg) if err: r += "

%s

\n" % (err) r += '
\n' r += " \n" % (img_type) r += " \n" % (img['id']) r += ' \n' if edit_mode: r += "
Name\n" % (img['name']) val = 'checked' if img['public'] else '' r += "
VisibilityPublic\n" % (val) r += '
 \n' else: val = 'Public' if img['public'] else 'Private' r += "
Visibility%s\n" % (val) if editable: r += '
 \n' r += "
Virtual Size%s\n" % (readable_size(img.virtual_size(), ONE_MB)) r += "
Physical Size%s\n" % (readable_size(img.physical_size(), ONE_MB)) r += '
  \n' pct = acp_progress(img['pathname']) if pct is None: if editable: r += '
 ' else: r += "
Copying%d%%\n" % (pct) r += '
\n' r += '
\n' else: r += "

%s Images

\n" % (img_type) if msg: r += "

%s

\n" % (msg) if err: r += "

%s

\n" % (err) r += '
\n' r += " \n" % (img_type) r += ' \n' r += ' \n' r += '
\n' r += '
\n' r += '
\n' r += ' \n' r += ' \n' idx = -1 cursor = table.select_all() if is_admin else table.select_where("owner='%s' OR public!=0" % (user['name'])) for row in cursor: img = Image(row) bgcolor = '#e0e0e0' if (idx % 2) == 0 else 'initial' idx += 1 r += " " % (bgcolor) r += "\n' r += '
NameOwnerVisibility 
%s" % (img_type, img['id'], img['name']) r += "%s" % (img['owner']) r += "%s" % ('Public' if img['public'] else 'Private') pct = acp_progress(img['pathname']) if pct is None: if img.type() == 'disk': r += "Launch" % (img['id']) else: r += " " else: r += "Creating %d%%" % (pct) r += '
\n' r += self._html_foot(user) self._send_response(200, None, r) def ui_image_create(self, user, args): r = self._html_head(user) msg = None err = None img_type = args['type'][0] if 'action' in args and args['action'][0] == 'Create': img = None name = args['name'][0] public = args['public'][0] if 'public' in args else False try: if args['image_source'][0] == 'upload_file': filename = args['upload_file.filename'][0] data = args['upload_file'][0] img = Image.create_from_upload(name, filename, data, user, public) if args['image_source'][0] == 'server_file': pathname = args['server_file'][0] if user.may_access_file(pathname): img = Image.create_from_local(name, pathname, user, public) else: err = 'Permission denied' if args['image_source'][0] == 'remote_url': url = args['remote_url'][0] img = Image.create_from_url(name, url, user, public) except BaseException as e: err = str(e) img = None if img: if img_type == 'iso': iso_images_table.insert(img) else: disk_images_table.insert(img) self._send_response(302, {'Location': "/ui/image?type=%s&id=%d" % (img_type, img['id'])}, None) r += '

Create Image

\n' if msg: r += "

%s

\n" % (msg) if err: r += "

%s

\n" % (err) r += ' \n' r += '
\n' r += " \n" % (img_type) r += ' \n' r += '
Name\n' r += '
Image Source\n' r += '
Upload File\n' r += '
Public \n' r += '
\n' r += '
\n' r += '
\n' r += self._html_foot(user) self._send_response(200, None, r) def ui_user(self, user, args): is_admin = user.in_group('admin') args_id = None r = self._html_head(user) if 'id' in args: args_id = int(args['id'][0]) if args_id != user['id'] and not is_admin: r += '

Access denied

\n' r += self._html_foot(user) self._send_response(403, None, r) return row = users_table.select_by_id(args_id) user = User(row) msg = None err = None token = None if 'action' in args: changed = False if args['action'][0] == 'Save': if (not is_admin) and ('localuid' in args): r += '

Access denied

\n' r += self._html_foot(user) self._send_response(403, None, r) return if 'fullname' in args: user.fullname(args['fullname'][0]) changed = True if 'oldpass' in args and 'newpass1' in args and 'newpass2' in args: if not err and not user.auth_password(args['oldpass'][0]): err = 'Old password is incorrect' if not err and args['newpass1'][0] != args['newpass2'][0]: err = 'New passwords do not match' if not err: user.password(args['newpass1'][0]) changed = True if 'email' in args: user.email(args['email'][0]) changed = True if 'timezone' in args: user.timezone(args['timezone'][0]) changed = True if 'localuid' in args: user.localuid(int(args['localuid'][0])) changed = True if args['action'][0] == 'Delete': if not is_admin: r += '

Access denied

\n' r += self._html_foot(user) self._send_response(403, None, r) return user_db.remove(user) msg = 'User deleted' args_id = None if args['action'][0] == 'Generate': now = int(time.time()) token = sha256_string("%s%s%s" % (user['name'], user['pwhash'], now)) user['token'] = token changed = True if changed: users_table.update(user) msg = 'Settings saved' r += '

Settings

\n' if msg: r += "

%s

\n" % (msg) if err: r += "

%s

\n" % (err) if is_admin and not args_id: r += '

Users

\n' r += '
\n' r += ' \n' r += ' \n' r += '
\n' r += '
\n' r += '
\n' r += ' \n' r += '
NameFull name \n' for row in users_table.select_all(): user = User(row) r += "
%s%s" % (user['id'], user['name'], user['fullname']) r += '
' r += "" % (user['id']) r += '\n' r += '
\n' r += '
\n' else: r += '
\n' r += " \n" % (user['id']) r += ' \n' r += '
Full name \n' r += "
 \n" % (user['fullname']) r += '
 \n' r += '
Password \n' r += '
 [old] \n' r += '
 [new] \n' r += '
 [confirm] \n' r += '
 \n' r += '
Email \n' r += "
 \n" % (user['email']) if is_admin: r += '
Local UID \n' r += "
 \n" % (user['localuid']) r += '
 \n' # r += '
Time zone \n' # r += "
 \n" % (user.timezone()) r += '
 \n' r += '
 \n' r += "
API token: %s \n" % (token if token else "[hidden]") r += '
 \n' r += '
\n' r += '
\n' r += self._html_foot(user) self._send_response(200, None, r) def ui_user_create(self, user, args): r = self._html_head(user) if not user.in_group('admin'): r += ' Access denied' r += self._html_foot(user) self._send_response(403, None, r) return msg = None err = None if 'action' in args and args['action'][0] == 'Add': name = args['name'][0] fullname = args['fullname'][0] password = args['password'][0] user = User.create(name, fullname, password, []) users_table.insert(user) msg = 'User created' r += '

Add user

\n' if msg: r += "

%s

\n" % (msg) if err: r += "

%s

\n" % (err) r += '
\n' r += '

User name

\n' r += '
\n' r += '

Full name

\n' r += '
\n' r += '

Password

\n' r += '
\n' r += '
\n' r += '
\n' r += '
\n' r += self._html_foot(user) self._send_response(200, None, r) # list, delete, start/reset/poweroff/kill on main page # start/reset/poweroff/kill, insert/eject on detail page def ui_vm(self, user, args): # XXX: auth r = self._html_head(user) err = None msg = None is_admin = user.in_group('admin') if 'id' in args: args_id = int(args['id'][0]) row = vms_table.select_by_id(args_id) vm = VirtualMachine(row) if vm['owner'] != user['name'] and not is_admin: r += '

Access denied

\n' r += self._html_foot(user) self._send_response(403, None, r) return else: args_id = None vm = None if vm: server_host = self.headers['Host'] if ':' in server_host: server_host = server_host.split(':')[0] vm_running = vm.running() edit_mode = (not vm_running) and ('action' in args) and (args['action'][0] == 'Edit') if 'action' in args: if args['action'][0] == 'Save': if 'name' in args: vm['name'] = args['name'][0] if 'arch' in args: vm['arch'] = args['arch'][0] if 'cpus' in args: vm['cpus'] = int(args['cpus'][0]) if 'mem' in args: mem = parse_num(args['mem'][0]) if mem >= ONE_MB: mem /= ONE_MB vm['mem'] = mem if 'ostype' in args: vm['ostype'] = args['ostype'][0] if 'vncpass' in args: vm['vncpass'] = args['vncpass'][0] vms_table.update(vm) msg = 'Settings saved' if args['action'][0] == 'Start': ro = 'readonly' in args vm.start(readonly=ro) if args['action'][0] == 'Suspend': vm.suspend() if args['action'][0] == 'Power Off': vm.poweroff() if args['action'][0] == 'Kill': vm.kill() if args['action'][0] == 'Insert': id = int(args['iso_image'][0]) row = iso_images_table.select_by_id(id) vm.iso_insert(row['pathname']) vms_table.update(vm) if args['action'][0] == 'Eject': vm.iso_eject() vms_table.update(vm) if args['action'][0] == 'Delete': if not vm_running: if vm['diskpath'].startswith(config['disk.storage.location']): file_delete(vm['diskpath']) vms_table.delete(vm) self._send_response(302, {'Location': '/ui/vm'}, None) else: err = 'Cannot delete: machine is running' if args['action'][0] == 'Create Image': name = args['image_name'][0] public = args['image_public'][0] img = Image.create_from_vmdisk(name, vm, user, public) disk_images_table.insert(img) vm_running = vm.running() edit_mode = (not vm_running) and ('action' in args) and (args['action'][0] == 'Edit') r += "

%s

\n" % (vm['name']) if msg: r += "

%s

\n" % (msg) if err: r += "

%s

\n" % (err) r += '
\n' r += " \n" % (args_id) r += ' \n' if edit_mode: r += "
Name\n" % (vm['name']) r += "
Arch%s\n" % (self._arch_select(vm['arch'])) r += "
CPUs\n" % (vm['cpus']) r += "
Mem\n" % (readable_size(vm['mem'], ONE_MB)) r += '
OS Type%s\n' % (self._ostype_select(vm['ostype'])) r += "
VNC Pass\n" % (vm['vncpass']) r += '
 \n' else: r += "
Arch%s\n" % (vm['arch']) r += "
CPUs%d\n" % (vm['cpus']) r += "
Mem%s\n" % (readable_size(vm['mem'], ONE_MB)) r += "
OS type%s\n" % (vm['ostype']) if not vm_running: r += '
 \n' r += "
Disk%s\n" % (readable_size(vm.disk_virtual_size(), ONE_MB)) r += "
State%s\n" % (vm.state()) r += "
MAC%s\n" % (vm['macaddr']) r += "
Addr%s\n" % (vm.ipv4addr()) r += '
  \n' if vm_running: r += "
VNC Host%s:%d\n" % (server_host, args_id) r += "
VNC Pass%s\n" % (vm['vncpass']) r += '
  \n' r += '
\n' r += ' \n' r += '
ISO' if vm['isopath']: row = iso_images_table.select_one_where("pathname='%s'" % (vm['isopath'])) r += "%s\n" % (row['name']) else: r += "%s\n" % (self._iso_image_select()) r += '
  \n' pct = acp_progress(vm['diskpath']) if vm_running: r += '
\n' r += '
\n' r += '
\n' elif not pct is None: r += "
Copying%d%%\n" % (pct) else: r += '
' # XXX: vm.disktype() if vm['diskpath'].endswith('.vmdk'): r += 'Read only mode\n' else: r += 'Read only mode\n' r += '
\n' r += '

\n' r += '
Create image from disk\n' r += '
Name\n' r += '
 Public\n' r += '
\n' r += '\n' r += '
\n' r += '
\n' else: r += '

Virtual Machines

\n' r += '
\n' r += ' \n' r += ' \n' r += '
\n' r += '
\n' r += '
\n' r += ' \n' r += ' \n' idx = -1 if user.in_group('admin'): cursor = vms_table.select_all() else: cursor = vms_table.select_where("owner=\"%s\"" % (user['name'])) for row in cursor: vm = VirtualMachine(row) idx += 1 bgcolor = '#e0e0e0' if (idx % 2) == 0 else 'initial' addr = ' ' # XXX: use vm_running for this if vm.running(): addr = vm.ipv4addr() r += " " % (bgcolor) r += "\n' r += '
NameStateAddress
%s" % (vm['id'], vm['name']) r += "%s" % (vm.state()) r += "%s" % (addr) # XXX: create-time (age), on-time (uptime) r += '
\n' r += self._html_foot(user) self._send_response(200, None, r) def ui_vm_create(self, user, args): r = self._html_head(user) msg = None err = None if 'action' in args and args['action'][0] == 'Create': vm = None name = args['name'][0] arch = args['arch'][0] cpus = int(args['cpus'][0]) if cpus < 1 or cpus > 8: err = 'Invalid CPUs' mem = parse_num(args['mem'][0]) if mem >= ONE_MB: mem /= ONE_MB if mem < 1 or mem > 16*1024: err = 'Invalid mem' try: if args['disk_source'][0] == 'create_new': disk_size = parse_num(args['disk_size'][0]) if disk_size >= ONE_MB: disk_size /= ONE_MB if disk_size < 1 or disk_size > 256*1024: err = 'Invalid disk size' vm = VirtualMachine.create_new(name, user, arch, cpus, mem, disk_size) if args['disk_source'][0] == 'use_image': image_id = int(args['disk_image'][0]) vm = VirtualMachine.create_from_image(name, user, arch, cpus, mem, image_id) if args['disk_source'][0] == 'upload_file': filename = args['upload_file.filename'][0] data = args['upload_file'][0] vm = VirtualMachine.create_from_upload(name, user, arch, cpus, mem, filename, data) if args['disk_source'][0] == 'server_file': pathname = args['server_file'][0] if user.may_access_file(pathname): vm = VirtualMachine.create_from_local(name, user, arch, cpus, mem. pathname) else: err = 'Permission denied' if args['disk_source'][0] == 'remote_url': image_url = args['remote_url'][0] vm = VirtualMachine.create_from_url(name, user, arch, cpus, mem, image_url) except BaseException as e: err = str(e) vm = None if vm: vms_table.insert(vm) self._send_response(302, {'Location': "/ui/vm?id=%d" % (vm['id'])}, None) img_id = int(args['img_id'][0]) if 'img_id' in args else None r += '

Create VM

\n' if msg: r += "

%s

\n" % (msg) if err: r += "

%s

\n" % (err) r += ' \n' r += '
\n' r += ' \n' r += ' \n' r += '
Name\n' r += "
Arch%s\n" % (self._arch_select()) r += '
CPUs\n' r += '
Memory\n' r += "
OS Type%s\n" % (self._ostype_select()) r += '
Disk Source\n' if img_id: r += '
Disk Image%s\n" % (self._disk_image_select(img_id)) else: r += '
Disk Size\n' r += "
Disk Image%s\n" % (self._disk_image_select(img_id)) r += '
\n' r += '
\n' r += '
\n' r += self._html_foot(user) self._send_response(200, None, r) def _handle_request(self): try: path = self.path args = {} if self.command == 'GET': if '?' in self.path: (path, query_string) = self.path.split('?', 1) args = urllib.parse.parse_qs(query_string) if self.command == 'POST': content_type = self.headers['Content-Type'] content_len = int(self.headers['Content-Length']) content = self.rfile.read(content_len) if content_type == 'application/x-www-form-urlencoded': args = urllib.parse.parse_qs(content.decode()) if content_type == 'application/json': args = json.loads(content.decode()) if content_type.startswith('multipart/form-data'): hdr_str = "Mime-Version: 1.0\r\nContent-Type: %s\r\n\r\n" % (content_type) parser = email.parser.BytesParser(policy=email.policy.default) msg = parser.parsebytes(hdr_str.encode() + content) args = {} for part in msg.walk(): disp = part.get('Content-Disposition', '') if not disp.startswith('form-data'): continue formdata = {} for field in disp.split(';'): if not '=' in field: continue (k,v) = field.strip().split('=', 1) v = v.strip('"') formdata[k] = v input_name = formdata['name'] if not input_name in args: args[input_name] = [] args[input_name].append(part.get_content()) if 'filename' in formdata: args["%s.filename" % (input_name)] = [formdata['filename']] user = None if 'Authorization' in self.headers: fields = self.headers['Authorization'].split() if len(fields) == 2 and fields[0] == 'Bearer': token = fields[1] row = user_table.select_one("token='%s'" % (token)) user = User(row) if 'Cookie' in self.headers: for kvp in self.headers['Cookie'].split(';'): (k, v) = kvp.strip().split('=', 1) if k == 'session': try: row = sessions_table.select_one_where("hash='%s'" % (v)) session = DbObject(row) row = users_table.select_by_id(session['user_id']) user = User(row) now = int(time.time()) session['expire'] = now + config['ui.session.duration'] sessions_table.update(session) except RuntimeError as e: pass if not user and path != '/ui/login': self._send_response(302, {'Location': '/ui/login'}, None) return if not path in self._dispatch_table: r = self._html_head(user) r += ' Page not found\n' r += self._html_foot(user) self._send_response(404, None, r) return self._dispatch_table[path](user, args) except BaseException as e: # XXX: send json for api requests r = self._html_head() r += '

Exception handling request

\n' r += "
%s
\n" % (e) if log_debug: # XXX: opts['debug'] r += '

Backtrace

\n' r += '
\n'
                for line in traceback.format_tb(e.__traceback__):
                    r += "%s\n" % (line)
                r += '  
\n' r += self._html_foot() self._send_response(500, None, r) def do_GET(self): self._handle_request() def do_POST(self): self._handle_request() def raw_http_listener(): listen_addr = (config['http.listen.address'], config['http.listen.port']) if sys.hexversion >= 0x030700f0: server = http.server.ThreadingHTTPServer(listen_addr, HttpClientRequestHandler) else: logw("HTTP server threading disabled because Python version is less than 3.7") server = http.server.HTTPServer(listen_addr, HttpClientRequestHandler) server.cookies = dict() server.serve_forever() def ssl_http_listener(): listen_addr = (config['http.listen.ssladdress'], config['http.listen.sslport']) if sys.hexversion >= 0x030700f0: server = http.server.ThreadingHTTPServer(listen_addr, HttpClientRequestHandler) else: logw("HTTPS server threading disabled because Python version is less than 3.7") server = http.server.HTTPServer(listen_addr, HttpClientRequestHandler) server.cookies = dict() server.socket = ssl.wrap_socket(server.socket, certfile=config['ssl.certfile'], server_side=True) server.serve_forever() ### File copier ### _acp_lock = threading.Lock() _acp_queue = [] _acp_map = {} _acp_src = None _acp_dst = None _acp_pct = None def acp_queue(src, dst): _acp_lock.acquire() _acp_queue.append(dst) _acp_map[dst] = src _acp_lock.release() def acp_progress(dst): pct = None _acp_lock.acquire() if dst in _acp_map: pct = 0 if _acp_dst == dst: pct = _acp_pct _acp_lock.release() return pct def acp_simple(srcfile, dstfile, file_size): global _acp_pct off = 0 pct = 0 while off < file_size: chunk = min(1024*1024, file_size - off) buf = srcfile.read(chunk) if len(buf) == 0: raise RuntimeError("Failed to read") dstfile.write(buf) off += len(buf) curpct = int(100 * off / file_size) if curpct != pct: pct = curpct _acp_lock.acquire() _acp_pct = pct _acp_lock.release() # XXX: could provide better status with disk blocks and copied size def acp_sparse(srcfile, dstfile, file_size): global _acp_pct off = 0 pct = 0 while off < file_size: try: off = srcfile.seek(off, os.SEEK_DATA) except OSError as e: if e.errno == errno.ENXIO: dstfile.truncate(file_size) return raise end = srcfile.seek(off, os.SEEK_HOLE) srcfile.seek(off) dstfile.seek(off) while off < end: chunk = min(1024*1024, end - off) buf = srcfile.read(chunk) if len(buf) == 0: raise RuntimeError("Failed to read") dstfile.write(buf) off += len(buf) curpct = int(100 * off / file_size) if curpct != pct: pct = curpct _acp_lock.acquire() _acp_pct = pct _acp_lock.release() def file_copier(): global _acp_src global _acp_dst global _acp_pct while running: try: src = None _acp_lock.acquire() if _acp_queue: dst = _acp_queue.pop(0) src = _acp_map[dst] _acp_lock.release() if src: _acp_src = src _acp_dst = dst _acp_pct = 0 if src.startswith('/'): srcfile = open(src, 'rb') dstfile = open(dst, 'wb') size = os.stat(src).st_size acp_sparse(srcfile, dstfile, size) else: srcfile = urllib.request.urlopen(src) dstfile = open(dst, 'wb') size = int(srcfile.headers.get('Content-Length', '0')) acp_simple(srcfile, dstfile, size) _acp_src = None _acp_dst = None _acp_lock.acquire() del _acp_map[dst] _acp_lock.release() except BaseException as e: loge("Exception in file_copy thread: %s" % (e)) time.sleep(1) ### Lease updater ### def lease_updater(): global leases leases_pathname = "%s/dnsmasq.leases" % (state_dir) last_mtime = 0 while running: try: cur_mtime = os.stat(leases_pathname) if cur_mtime != last_mtime: new_leases = {} last_mtime = cur_mtime f = open(leases_pathname, 'r') for line in f: fields = line.split() macaddr = fields[1] ipv4addr = fields[2] name = fields[3] new_leases[macaddr] = ipv4addr f.close() leases = new_leases except OSError as e: last_mtime = 0 except BaseException as e: loge("Exception in lease_updater thread: %s" % (e)) time.sleep(1) ### Session expirer ### def session_expirer(): while running: now = int(time.time()) sessions_table.delete_where("expire < %d" % (now)) time.sleep(60) ### Main ### config_defaults = { 'http.listen.address': '127.0.0.1', 'http.listen.port': 8080, 'ui.imagepath': 'https://www.nwwn.com/vmm/images', 'ui.session.duration': 3600, 'iso.storage.location': '/var/lib/vmm/isos', 'image.storage.location': '/var/lib/vmm/images', 'disk.storage.type': 'dir', 'disk.storage.location': '/var/lib/vmm/disks', 'disk.storage.name': '%u.%n', 'network.mode': 'manual', 'network.bridge.name': 'br0', 'network.bridge.mode': 'subnet', 'network.bridge.addr': '172.16.1.1/24', 'network.dhcp.start': '172.16.1.2', 'network.dhcp.end': '172.16.1.254', 'network.dns.server': '1.1.1.1', 'network.proxy_arp_dev': 'eth0', 'vm.nesting': True, 'vm.max_cpu': '', 'vm.max_mem': '', 'vm.max_disk': '', 'user.max_cpu': '', 'user.max_mem': '', 'user.max_disk': '' } config = Config('/etc/vmm/config', config_defaults) global_opts = 'dv' global_longopts = ['debug', 'verbose'] optargs, argv = getopt.getopt(sys.argv[1:], global_opts, global_longopts) for k,v in optargs: if k in ('-d', '--debug'): log_debug = True if k in ('-v', '--verbose'): log_verbose += 1 mkdir_p(state_dir) mkdir_p(run_dir) mkdir_p(config['iso.storage.location']) mkdir_p(config['image.storage.location']) mkdir_p(config['disk.storage.location']) # Initialize database dbfile = "%s/vmm.sqlite" % (state_dir) dbconn = sqlite3.connect(dbfile, check_same_thread=False) os.chmod(dbfile, 0o0600) dbconn.isolation_level = None dbconn.row_factory = sqlite3.Row users_table = DbTable(dbconn, 'users', """id INTEGER PRIMARY KEY, name VARCHAR(64), fullname VARCHAR(64), pwhash CHAR(64), groups VARCHAR(256), localuid INTEGER, email VARCHAR(256), timezone INTEGER, token CHAR(64)""") iso_images_table = DbTable(dbconn, 'iso_images', """id INTEGER PRIMARY KEY, owner VARCHAR(64), name VARCHAR(256), pathname VARCHAR(256), public INTEGER""") disk_images_table = DbTable(dbconn, 'disk_images', """id INTEGER PRIMARY KEY, owner VARCHAR(64), name VARCHAR(256), pathname VARCHAR(256), public INTEGER""") vms_table = DbTable(dbconn, 'vms', """id INTEGER PRIMARY KEY, owner VARCHAR(64), name VARCHAR(256), arch VARCHAR(64), cpus INTEGER, mem INTEGER, ostype VARCHAR(64), vncpass CHAR(8), uuid CHAR(36), macaddr CHAR(17), disksize INTEGER, diskpath VARCHAR(256), isopath VARCHAR(256)""") sessions_table = DbTable(dbconn, 'sessions', """id INTEGER PRIMARY KEY, hash CHAR(64), user_id INTEGER, expire INTEGER""") if users_table.empty(): root = User.create('root', 'Root User', 'root', 'admin') users_table.insert(root) else: root = users_table.select_by_name('root') if iso_images_table.empty(): for filename in os.listdir(config['iso.storage.location']): pathname = "%s/%s" % (config['iso.storage.location'], filename) (name, ext) = os.path.splitext(filename) if not ext in ['.iso']: continue img = Image.create_from_local(name, pathname, root, 1) iso_images_table.insert(img) # XXX: else check removed if disk_images_table.empty(): for filename in os.listdir(config['image.storage.location']): pathname = "%s/%s" % (config['image.storage.location'], filename) (name, ext) = os.path.splitext(filename) if not ext in ['.vmdk', '.qcow2']: continue img = Image.create_from_local(name, pathname, root, 1) disk_images_table.insert(img) # XXX: else check removed if vms_table.empty(): # XXX: handle LVM for filename in os.listdir(config['disk.storage.location']): pathname = "%s/%s" % (config['disk.storage.location'], filename) fields = filename.split('.') # XXX: parse better: username.vmname.ext if len(fields) != 3: continue if not fields[2] in ['qcow2', 'vmdk']: continue vm = VirtualMachine.create_from_local(fields[1], fields[0], 'x86_64', 2, 4096, pathname) vms_table.insert(vm) # XXX: else check removed # Setup networking if config['network.mode'] == 'bridge': argv = ['brctl', 'addbr', config['network.bridge.name']] try: cmd_run(argv) except: # XXX: handle errors other than already exists pass argv = ['ip', 'addr', 'add', config['network.bridge.addr'], 'dev', config['network.bridge.name']] try: cmd_run(argv) except: # XXX: handle errors other than already exists pass argv = ['ip', 'link', 'set', config['network.bridge.name'], 'up'] try: cmd_run(argv) except: loge("Cannot activate bridge device") sys.exit(1) if 'network.proxy_arp_dev' in config: pathname = "/proc/sys/net/ipv4/conf/%s/proxy_arp" % (config['network.proxy_arp_dev']) file_write(pathname, "1") dnsmasq_pid = pidfile_read('dnsmasq') if not pid_exists(dnsmasq_pid): logi("Starting dnsmasq...") cfg_filename = "%s/dnsmasq-vmm.conf" % (run_dir) f = open(cfg_filename, 'w') f.write("pid-file=%s/dnsmasq.pid\n" % (run_dir)) f.write("interface=%s\n" % (config['network.bridge.name'])) f.write("except-interface=lo\n") f.write("no-hosts\n") f.write("no-resolv\n") # DNS f.write("local=%s\n" % (config['network.dns.server'])) # DHCP f.write("dhcp-leasefile=%s/dnsmasq.leases\n" % (state_dir)) f.write("dhcp-range=%s,%s\n" % (config['network.dhcp.start'], config['network.dhcp.end'])) f.write("dhcp-authoritative\n") f.close() args = ['dnsmasq'] args.append("--conf-file=%s" % (cfg_filename)) fork_child(args) else: sys.stderr.write("FIXME: implement non-bridge networking\n") sys.exit(1) for entry in os.listdir(run_dir): path = "%s/%s" % (run_dir, entry) if not os.path.isdir(entry): continue id = int(entry, 16) row = vms_table.select_by_id(id) vm = VirtualMachine(row) vms_running.append(vm) # XXX: If not debug/foreground, daemonize signal.signal(signal.SIGTERM, sig_term) signal.signal(signal.SIGINT, sig_term) signal.signal(signal.SIGCHLD, sig_chld) file_copier_thread = threading.Thread(target=file_copier) file_copier_thread.daemon = True file_copier_thread.start() lease_updater_thread = threading.Thread(target=lease_updater) lease_updater_thread.daemon = True lease_updater_thread.start() session_expirer_thread = threading.Thread(target=session_expirer) session_expirer_thread.daemon = True session_expirer_thread.start() if 'cli.listen.address' in config: raw_cli_listener_thread = threading.Thread(target=raw_cli_listener) raw_cli_listener_thread.daemon = True raw_cli_listener_thread.start() if 'cli.listen.ssladdress' in config: ssl_cli_listener_thread = threading.Thread(target=ssl_cli_listener) ssl_cli_listener_thread.daemon = True ssl_cli_listener_thread.start() if 'http.listen.address' in config: raw_http_listener_thread = threading.Thread(target=raw_http_listener) raw_http_listener_thread.daemon = True raw_http_listener_thread.start() if 'http.listen.ssladdress' in config: ssl_http_listener_thread = threading.Thread(target=ssl_http_listener) ssl_http_listener_thread.daemon = True ssl_http_listener_thread.start() while running: time.sleep(1) for table in [users_table, iso_images_table, disk_images_table, vms_table]: table._lock.acquire() dbconn.close()