2618 lines
96 KiB
Python
Executable File
2618 lines
96 KiB
Python
Executable File
#!/usr/bin/python3
|
|
|
|
# API:
|
|
# - GET /api/user
|
|
# - GET /api/user?id=#
|
|
# - POST /api/user (id=#, action=<edit|delete|...> ...)
|
|
# - 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=<none | manual | bridge> { manual }
|
|
# network.bridge.name=<name> { br0 }
|
|
# network.bridge.mode=<manual | subnet | nat> { subnet |
|
|
# network.bridge.addr=<ipaddr> { 172.16.1.1/24 }
|
|
# network.dhcp.start=<ipaddr> { based on bridge addr }
|
|
# network.dhcp.end=<ipaddr> { based on bridge addr }
|
|
#
|
|
# nic.mac.start=<macaddr> { 52:54:00:00:00:00 }
|
|
#
|
|
# iso.storage.location=<path> { /var/lib/vmm/isos }
|
|
# image.storage.location=<path> { /var/lib/vmm/images }
|
|
#
|
|
# disk.storage.type=<dir | lvm> { dir }
|
|
# disk.storage.location=<path | name> { /var/lib/vmm/disk | vg0 }
|
|
# disk.storage.name=<format> { %u.%n }
|
|
#
|
|
# vm.nesting=<bool> { 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)
|
|
if resuming:
|
|
self._run_monitor_command('delvm vmm-suspend', 5.0)
|
|
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 <name|id> <password|token>')
|
|
self.send_msg('serial <name|id>')
|
|
self.send_msg('monitor <name|id>')
|
|
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 += '<html>\n'
|
|
r += ' <head>\n'
|
|
r += ' <title>Virtual Machine Manager</title>\n'
|
|
r += ' <style>\n'
|
|
r += ' a { color: blue; text-decoration: none; }\n'
|
|
r += ' </style>\n'
|
|
r += ' </head>\n'
|
|
r += ' <body>\n'
|
|
r += ' <table width="100%">\n'
|
|
r += ' <tr>\n'
|
|
r += ' <td width="33%"> \n'
|
|
r += " <td width=\"33%%\" align=\"center\"><img src=\"%s/logo.jpg\" alt=\"logo\">\n" % (config['ui.imagepath'])
|
|
if user:
|
|
r += " <td width=\"33%%\" align=\"right\" valign=\"top\">%s<br><a href=\"/ui/logout\">logout</a>\n" % (user['fullname'])
|
|
else:
|
|
r += ' <td width="33%"> \n'
|
|
r += ' <tr>\n'
|
|
r += ' <td> \n'
|
|
r += ' <td align="center"><p style="font-size:150%">Virtual Machine Manager</p>\n'
|
|
r += ' <td> \n'
|
|
r += ' </table>\n'
|
|
r += ' <hr width="50%">\n'
|
|
if user:
|
|
r += ' <table width="100%">\n'
|
|
r += ' <tr>\n'
|
|
r += ' <td width="20%" style="vertical-align:top">\n'
|
|
if user.in_group('admin'):
|
|
r += ' <p style="color:red">Admin</p>\n'
|
|
r += ' <hr>\n'
|
|
r += ' <a href="/ui">Overview</a><br>\n'
|
|
r += ' <br>\n'
|
|
r += ' <a href="/ui/vm">Virtual Machines</a><br>\n'
|
|
r += ' <br>\n'
|
|
r += ' <a href="/ui/image?type=disk">Disk Images</a><br>\n'
|
|
r += ' <br>\n'
|
|
r += ' <a href="/ui/image?type=iso">ISO Images</a><br>\n'
|
|
r += ' <br>\n'
|
|
r += ' <a href="/ui/user">Settings</a><br>\n'
|
|
r += ' <br>\n'
|
|
r += ' <td align="left" valign="top">\n'
|
|
return r
|
|
|
|
def _html_foot(self, user=None):
|
|
r = ''
|
|
if user:
|
|
r += ' </table>\n'
|
|
r += ' </body>\n'
|
|
r += '</html>\n'
|
|
return r
|
|
|
|
def _arch_select(self, arch=None):
|
|
r = ''
|
|
r += '<select name="arch">'
|
|
for val in [ 'x86_64', 'arm64', 'rpi3' ]:
|
|
sel = ' selected="true"' if val == arch else ''
|
|
r += "<option value=\"%s\"%s>%s" % (val, sel, val)
|
|
r += '</select>'
|
|
return r
|
|
|
|
def _ostype_select(self, ostype=None):
|
|
r = ''
|
|
r += '<select name="ostype">'
|
|
for val in [ 'Linux', 'Windows', 'MacOS' ]:
|
|
sel = ' selected="true"' if val == ostype else ''
|
|
r += "<option value=\"%s\"%s>%s" % (val, sel, val)
|
|
r += '</select>'
|
|
return r
|
|
|
|
def _iso_image_select(self):
|
|
r = ''
|
|
r += '<select name="iso_image">'
|
|
r += '<option value="">'
|
|
for row in iso_images_table.select_all():
|
|
r += "<option value=\"%d\">%s" % (row['id'], row['name'])
|
|
r += '</select>'
|
|
return r
|
|
|
|
def _disk_image_select(self, img_id=None):
|
|
r = ''
|
|
r += '<select name="disk_image">'
|
|
r += '<option value=""></option>'
|
|
for row in disk_images_table.select_all():
|
|
sel = ' selected="true"' if row['id'] == img_id else ''
|
|
r += "<option value=\"%d\"%s>%s" % (row['id'], sel, row['name'])
|
|
r += '</select>'
|
|
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 += ' <div align="center">\n'
|
|
r += ' <form method="POST" action="/ui/login">\n'
|
|
r += ' <table align="center">\n'
|
|
r += ' <tr><td> </tr>\n'
|
|
r += ' <tr><td style="font-size:150%"><b>Login</b></tr>\n'
|
|
r += ' <tr><td> </tr>\n'
|
|
r += ' <tr><td><b>Username</b></tr>\n'
|
|
r += ' <tr><td><input type="text" name="username"/></tr>\n'
|
|
r += ' <tr><td><b>Password</b></br>\n'
|
|
r += ' <tr><td><input type="password" name="password"/></tr>\n'
|
|
r += ' <tr><td align="center"><input type="submit" value="Login"/></tr>\n'
|
|
r += ' </table>\n'
|
|
r += ' </form>\n'
|
|
if msg:
|
|
r += " <h2>%s</h2>\n" % (msg)
|
|
r += ' </div>\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 += ' <p style="font-size:150%">Overview</p>\n'
|
|
r += ' <table width="100%">\n'
|
|
r += ' <tr><td> <td style="font-weight:bold">Active<td style="font-weight:bold">Total\n'
|
|
r += " <tr><td style=\"font-weight:bold\">CPUs<td>%d<td>%d\n" % (active['cpus'], total['cpus'])
|
|
r += " <tr><td style=\"font-weight:bold\">Memory<td>%s<td>%s\n" % (readable_size(active['mem'], ONE_MB), readable_size(total['mem'], ONE_MB))
|
|
r += " <tr><td style=\"font-weight:bold\">Physical Disk<td>%s<td>%s\n" % (readable_size(active['phys_disk'], ONE_MB), readable_size(total['phys_disk'], ONE_MB))
|
|
r += " <tr><td style=\"font-weight:bold\">Virtual Disk<td>%s<td>%s\n" % (readable_size(active['virt_disk'], ONE_MB), readable_size(total['virt_disk'], ONE_MB))
|
|
r += ' </table>\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 += ' <p>Access denied</p>\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 += " <p style=\"font-size:150%%\">%s</p>\n" % (img['name'])
|
|
if msg:
|
|
r += " <p style=\"font-size:125%%\">%s</p>\n" % (msg)
|
|
if err:
|
|
r += " <p style=\"font-size:125%%;color:red\">%s</p>\n" % (err)
|
|
r += ' <form method="POST" action="/ui/image">\n'
|
|
r += " <input type=\"hidden\" name=\"type\" value=\"%s\">\n" % (img_type)
|
|
r += " <input type=\"hidden\" name=\"id\" value=\"%d\">\n" % (img['id'])
|
|
r += ' <table>\n'
|
|
if edit_mode:
|
|
r += " <tr><td style=\"font-weight:bold\">Name<td><input type=\"text\" name=\"name\" value=\"%s\">\n" % (img['name'])
|
|
val = 'checked' if img['public'] else ''
|
|
r += " <tr><td style=\"font-weight:bold\">Visibility<td><input type=\"checkbox\" name=\"public\" %s>Public\n" % (val)
|
|
r += ' <tr><td><input type="submit" name="action" value="Save"><td> \n'
|
|
else:
|
|
val = 'Public' if img['public'] else 'Private'
|
|
r += " <tr><td style=\"font-weight:bold\">Visibility<td>%s\n" % (val)
|
|
if editable:
|
|
r += ' <tr><td><input type="submit" name="action" value="Edit"><td> \n'
|
|
r += " <tr><td style=\"font-weight:bold\">Virtual Size<td>%s\n" % (readable_size(img.virtual_size(), ONE_MB))
|
|
r += " <tr><td style=\"font-weight:bold\">Physical Size<td>%s\n" % (readable_size(img.physical_size(), ONE_MB))
|
|
r += ' <tr><td> <td> \n'
|
|
pct = acp_progress(img['pathname'])
|
|
if pct is None:
|
|
if editable:
|
|
r += ' <tr><td><input style="color:red" type="submit" name="action" value="Delete"><td> '
|
|
else:
|
|
r += " <tr><td style=\"font-weight:bold\">Copying<td>%d%%\n" % (pct)
|
|
r += ' </table>\n'
|
|
r += ' </form>\n'
|
|
else:
|
|
r += " <p style=\"font-size:150%%\">%s Images</p>\n" % (img_type)
|
|
if msg:
|
|
r += " <p style=\"font-size:125%%\">%s</p>\n" % (msg)
|
|
if err:
|
|
r += " <p style=\"font-size:125%%;color:red\">%s</p>\n" % (err)
|
|
r += ' <form method="GET" action="/ui/image/create">\n'
|
|
r += " <input type=\"hidden\" name=\"type\" value=\"%s\">\n" % (img_type)
|
|
r += ' <table width="100%">\n'
|
|
r += ' <tr>\n'
|
|
r += ' <td><input type="submit" value="Create">\n'
|
|
r += ' </table>\n'
|
|
r += ' </form>\n'
|
|
r += ' <table width="100%">\n'
|
|
r += ' <tr style="font-weight:bold"><td>Name<td>Owner<td>Visibility<td> </tr>\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 += " <tr style=\"background-color:%s\">" % (bgcolor)
|
|
r += "<td><a href=\"/ui/image?type=%s&id=%d\">%s</a>" % (img_type, img['id'], img['name'])
|
|
r += "<td>%s" % (img['owner'])
|
|
r += "<td>%s" % ('Public' if img['public'] else 'Private')
|
|
pct = acp_progress(img['pathname'])
|
|
if pct is None:
|
|
if img.type() == 'disk':
|
|
r += "<td><a href=\"/ui/vm/create?img_id=%d\">Launch</a>" % (img['id'])
|
|
else:
|
|
r += "<td> "
|
|
else:
|
|
r += "<td>Creating %d%%" % (pct)
|
|
r += '</tr>\n'
|
|
r += ' </table>\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 += ' <p style="font-size:150%">Create Image</p>\n'
|
|
if msg:
|
|
r += " <p style=\"font-size:125%%\">%s</p>\n" % (msg)
|
|
if err:
|
|
r += " <p style=\"font-size:125%%;color:red\">%s</p>\n" % (err)
|
|
r += ' <script>\n'
|
|
r += ' function imageSourceSelected(sel) {\n'
|
|
r += ' var vec = [\n'
|
|
r += ' document.getElementById("image_upload_row"),\n'
|
|
r += ' document.getElementById("image_local_row"),\n'
|
|
r += ' document.getElementById("image_remote_row")\n'
|
|
r += ' ];\n'
|
|
r += ' for (i = 0; i < 3; i++) {\n'
|
|
r += ' if (sel.selectedIndex == i) {\n'
|
|
r += ' vec[i].style.display = "table-row";\n'
|
|
r += ' }\n'
|
|
r += ' else {\n'
|
|
r += ' vec[i].style.display = "none";\n'
|
|
r += ' }\n'
|
|
r += ' }\n'
|
|
r += ' }\n'
|
|
r += ' </script>\n'
|
|
r += ' <form method="POST" action="/ui/image/create" enctype="multipart/form-data">\n'
|
|
r += " <input type=\"hidden\" name=\"type\" value=\"%s\">\n" % (img_type)
|
|
r += ' <table>\n'
|
|
r += ' <tr><td style="font-weight:bold">Name<td><input type="text" name="name">\n'
|
|
r += ' <tr><td style="font-weight:bold">Image Source<td><select name="image_source" onChange="imageSourceSelected(this);">'
|
|
r += '<option value="upload_file">Upload File</option>'
|
|
r += '<option value="server_file">Server File</option>'
|
|
r += '<option value="remote_url">Remote URL</option>'
|
|
r += '</select>\n'
|
|
r += ' <tr style="display:table-row" id="image_upload_row"><td style="font-weight:bold">Upload File<td><input type="file" name="upload_file">\n'
|
|
r += ' <tr style="display:none" id="image_local_row"><td style="font-weight:bold">Server File<td><input type="text" name="server_file" size="32">\n'
|
|
r += ' <tr style="display:none" id="image_remote_row"><td style="font-weight:bold">Remote URL<td><input type="text" name="remote_url" size="32">\n'
|
|
r += ' <tr><td><input type="checkbox" name="public">Public<td> \n'
|
|
r += ' <tr><td colspan="2" align="center"><input type="submit" name="action" value="Create">\n'
|
|
r += ' </table>\n'
|
|
r += ' </form>\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 += ' <p>Access denied</p>\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 += ' <p>Access denied</p>\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 += ' <p>Access denied</p>\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 += ' <p style="font-size:150%">Settings</p>\n'
|
|
if msg:
|
|
r += " <p style=\"font-size:125%%\">%s</p>\n" % (msg)
|
|
if err:
|
|
r += " <p style=\"font-size:125%%;color:red\">%s</p>\n" % (err)
|
|
if is_admin and not args_id:
|
|
r += ' <p style="font-size:125%">Users</p>\n'
|
|
r += ' <form method="GET" action="/ui/user/create">\n'
|
|
r += ' <table width="100%">\n'
|
|
r += ' <tr>\n'
|
|
r += ' <td><input type="submit" value="Create">\n'
|
|
r += ' </table>\n'
|
|
r += ' </form>\n'
|
|
r += ' <table width="100%">\n'
|
|
r += ' <tr><td>Name<td>Full name<td> \n'
|
|
for row in users_table.select_all():
|
|
user = User(row)
|
|
r += " <tr><td><a href=\"/ui/user?id=%d\">%s</a><td>%s" % (user['id'], user['name'], user['fullname'])
|
|
r += '<td><form method="POST" action="/ui/user">'
|
|
r += "<input type=\"hidden\" name=\"id\" value=\"%d\">" % (user['id'])
|
|
r += '<input type="submit" name="action" value="Delete">\n'
|
|
r += '</form>\n'
|
|
r += ' </table>\n'
|
|
else:
|
|
r += ' <form method="POST" action="/ui/user">\n'
|
|
r += " <input type=\"hidden\" name=\"id\" value=%d>\n" % (user['id'])
|
|
r += ' <table width="100%">\n'
|
|
r += ' <tr><td style="font-size:80%;font-weight:bold">Full name<td> \n'
|
|
r += " <tr><td><input type=\"text\" name=\"fullname\" value=\"%s\"><td> \n" % (user['fullname'])
|
|
r += ' <tr><td colspan="2"> \n'
|
|
r += ' <tr><td style="font-size:80%;font-weight:bold">Password<td> \n'
|
|
r += ' <tr><td><input type="password" name="oldpass"> [old]<td> \n'
|
|
r += ' <tr><td><input type="password" name="newpass1"> [new]<td> \n'
|
|
r += ' <tr><td><input type="password" name="newpass2"> [confirm]<td> \n'
|
|
r += ' <tr><td colspan="2"> \n'
|
|
r += ' <tr><td style="font-size:80%;font-weight:bold">Email<td> \n'
|
|
r += " <tr><td><input type=\"text\" name=\"email\" value=\"%s\"><td> \n" % (user['email'])
|
|
if is_admin:
|
|
r += ' <tr><td style="font-size:80%;font-weight:bold">Local UID<td> \n'
|
|
r += " <tr><td><input type=\"number\" name=\"localuid\" value=\"%s\"<td> \n" % (user['localuid'])
|
|
r += ' <tr><td colspan="2"> \n'
|
|
# r += ' <tr><td style="font-size:80%;font-weight:bold">Time zone<td> \n'
|
|
# r += " <tr><td><input type=\"text\" name=\"timezone\" value=\"%s\"><td> \n" % (user.timezone())
|
|
r += ' <tr><td><input type="submit" name="action" value="Save"><td> \n'
|
|
r += ' <tr><td colspan="2"> \n'
|
|
r += " <tr><td>API token: %s<td> \n" % (token if token else "[hidden]")
|
|
r += ' <tr><td><input type="submit" name="action" value="Generate"><td> \n'
|
|
r += ' </table>\n'
|
|
r += ' </form>\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 += ' <p style="font-size:150%">Add user</p>\n'
|
|
if msg:
|
|
r += " <p style=\"font-size:125%%\">%s</p>\n" % (msg)
|
|
if err:
|
|
r += " <p style=\"font-size:125%%;color:red\">%s</p>\n" % (err)
|
|
r += ' <form method="POST" action="/ui/user/create">\n'
|
|
r += ' <p style="font-size:80%;font-weight:bold">User name</p>\n'
|
|
r += ' <input type="text" name="name"><br>\n'
|
|
r += ' <p style="font-size:80%;font-weight:bold">Full name</p>\n'
|
|
r += ' <input type="text" name="fullname"><br>\n'
|
|
r += ' <p style="font-size:80%;font-weight:bold">Password</p>\n'
|
|
r += ' <input type="password" name="password"><br>\n'
|
|
r += ' <br>\n'
|
|
r += ' <input type="submit" name="action" value="Add"><br>\n'
|
|
r += ' </form>\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 += ' <p>Access denied</p>\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 += " <p style=\"font-size:150%%\">%s</p>\n" % (vm['name'])
|
|
if msg:
|
|
r += " <p style=\"font-size:125%%\">%s</p>\n" % (msg)
|
|
if err:
|
|
r += " <p style=\"font-size:125%%;color:red\">%s</p>\n" % (err)
|
|
r += ' <form method="POST" action="/ui/vm">\n'
|
|
r += " <input type=\"hidden\" name=\"id\" value=\"%d\">\n" % (args_id)
|
|
r += ' <table>\n'
|
|
if edit_mode:
|
|
r += " <tr><td style=\"font-weight:bold\">Name<td><input type=\"text\" name=\"name\" value=\"%s\">\n" % (vm['name'])
|
|
r += " <tr><td style=\"font-weight:bold\">Arch<td>%s\n" % (self._arch_select(vm['arch']))
|
|
r += " <tr><td style=\"font-weight:bold\">CPUs<td><input type=\"number\" name=\"cpus\" value=\"%d\" size=\"6\">\n" % (vm['cpus'])
|
|
r += " <tr><td style=\"font-weight:bold\">Mem<td><input type=\"text\" name=\"mem\" value=\"%s\" size=\"6\">\n" % (readable_size(vm['mem'], ONE_MB))
|
|
r += ' <tr><td style="font-weight:bold">OS Type<td>%s\n' % (self._ostype_select(vm['ostype']))
|
|
r += " <tr><td style=\"font-weight:bold\">VNC Pass<td><input type=\"text\" name=\"vncpass\" value=\"%s\" size=\"8\">\n" % (vm['vncpass'])
|
|
r += ' <tr><td><input type="submit" name="action" value="Save"><td> \n'
|
|
else:
|
|
r += " <tr><td style=\"font-weight:bold\">Arch<td>%s\n" % (vm['arch'])
|
|
r += " <tr><td style=\"font-weight:bold\">CPUs<td>%d\n" % (vm['cpus'])
|
|
r += " <tr><td style=\"font-weight:bold\">Mem<td>%s\n" % (readable_size(vm['mem'], ONE_MB))
|
|
r += " <tr><td style=\"font-weight:bold\">OS type<td>%s\n" % (vm['ostype'])
|
|
if not vm_running:
|
|
r += ' <tr><td><input type="submit" name="action" value="Edit"><td> \n'
|
|
r += " <tr><td style=\"font-weight:bold\">Disk<td>%s\n" % (readable_size(vm.disk_virtual_size(), ONE_MB))
|
|
r += " <tr><td style=\"font-weight:bold\">State<td>%s\n" % (vm.state())
|
|
r += " <tr><td style=\"font-weight:bold\">MAC<td>%s\n" % (vm['macaddr'])
|
|
r += " <tr><td style=\"font-weight:bold\">Addr<td>%s\n" % (vm.ipv4addr())
|
|
r += ' <tr><td> <td> \n'
|
|
if vm_running:
|
|
r += " <tr><td style=\"font-weight:bold\">VNC Host<td>%s:%d\n" % (server_host, args_id)
|
|
r += " <tr><td style=\"font-weight:bold\">VNC Pass<td>%s\n" % (vm['vncpass'])
|
|
r += ' <tr><td> <td> \n'
|
|
r += ' </table>\n'
|
|
r += ' <table>\n'
|
|
r += ' <tr><td style="font-weight:bold">ISO'
|
|
if vm['isopath']:
|
|
row = iso_images_table.select_one_where("pathname='%s'" % (vm['isopath']))
|
|
r += "<td>%s<input style=\"float:right\" type=\"submit\" name=\"action\" value=\"Eject\">\n" % (row['name'])
|
|
else:
|
|
r += "<td>%s<input type=\"submit\" name=\"action\" value=\"Insert\">\n" % (self._iso_image_select())
|
|
r += ' <tr><td> <td> \n'
|
|
pct = acp_progress(vm['diskpath'])
|
|
if vm_running:
|
|
r += ' <tr><td colspan="2"><input type="submit" name="action" value="Suspend">\n'
|
|
r += ' <tr><td colspan="2"><input type="submit" name="action" value="Power Off">\n'
|
|
r += ' <tr><td colspan="2"><input type="submit" name="action" value="Kill">\n'
|
|
elif not pct is None:
|
|
r += " <tr><td style=\"font-weight:bold\">Copying<td>%d%%\n" % (pct)
|
|
else:
|
|
r += ' <tr><td colspan="2"><input type="submit" name="action" value="Start">'
|
|
# XXX: vm.disktype()
|
|
if vm['diskpath'].endswith('.vmdk'):
|
|
r += '<input type="checkbox" name="readonly" disabled checked>Read only mode\n'
|
|
else:
|
|
r += '<input type="checkbox" name="readonly">Read only mode\n'
|
|
r += ' <tr><td colspan="2"><input style="color:red" type="submit" name="action" value="Delete">\n'
|
|
r += ' <tr><td colspan="2"><hr>\n'
|
|
r += ' <tr><td colspan="2" style="font-size:125%">Create image from disk\n'
|
|
r += ' <tr><td style="font-weight:bold">Name<td><input type="text" name="image_name">\n'
|
|
r += ' <tr><td> <td><input type="checkbox" name="image_public">Public\n'
|
|
r += ' <tr><td colspan="2"><input type="submit" name="action" value="Create Image">\n'
|
|
r += '\n'
|
|
r += ' </table>\n'
|
|
r += ' </form>\n'
|
|
else:
|
|
r += ' <p style="font-size:150%">Virtual Machines</p>\n'
|
|
r += ' <form method="GET" action="/ui/vm/create">\n'
|
|
r += ' <table width="100%">\n'
|
|
r += ' <tr>\n'
|
|
r += ' <td><input type="submit" value="Create">\n'
|
|
r += ' </table>\n'
|
|
r += ' </form>\n'
|
|
r += ' <table with="100%">\n'
|
|
r += ' <tr style="font-weight:bold"><td>Name<td>State<td>Address</tr>\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 += " <tr style=\"background-color:%s\">" % (bgcolor)
|
|
r += "<td><a href=\"/ui/vm?id=%d\">%s</a>" % (vm['id'], vm['name'])
|
|
r += "<td>%s" % (vm.state())
|
|
r += "<td>%s" % (addr)
|
|
# XXX: create-time (age), on-time (uptime)
|
|
r += '</tr>\n'
|
|
r += ' </table>\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 += ' <p style="font-size:150%">Create VM</p>\n'
|
|
if msg:
|
|
r += " <p style=\"font-size:125%%\">%s</p>\n" % (msg)
|
|
if err:
|
|
r += " <p style=\"font-size:125%%;color:red\">%s</p>\n" % (err)
|
|
r += ' <script>\n'
|
|
r += ' function diskSourceSelected(sel) {\n'
|
|
r += ' var vec = [\n'
|
|
r += ' document.getElementById("disk_size_row"),\n'
|
|
r += ' document.getElementById("disk_image_row"),\n'
|
|
r += ' document.getElementById("disk_upload_row"),\n'
|
|
r += ' document.getElementById("disk_local_row"),\n'
|
|
r += ' document.getElementById("disk_remote_row")\n'
|
|
r += ' ];\n'
|
|
r += ' for (i = 0; i < 5; i++) {\n'
|
|
r += ' if (sel.selectedIndex == i) {\n'
|
|
r += ' vec[i].style.display = "table-row";\n'
|
|
r += ' }\n'
|
|
r += ' else {\n'
|
|
r += ' vec[i].style.display = "none";\n'
|
|
r += ' }\n'
|
|
r += ' }\n'
|
|
r += ' }\n'
|
|
r += ' </script>\n'
|
|
r += ' <form method="POST" action="/ui/vm/create">\n'
|
|
r += ' <table>\n'
|
|
r += ' <input type="hidden" name="arch" value="x86_64">\n'
|
|
r += ' <tr><td style="font-weight:bold">Name<td><input type="text" name="name">\n'
|
|
r += " <tr><td style=\"font-weight:bold\">Arch<td>%s\n" % (self._arch_select())
|
|
r += ' <tr><td style="font-weight:bold">CPUs<td><input type="number" name="cpus" size="4">\n'
|
|
r += ' <tr><td style="font-weight:bold">Memory<td><input type="text" name="mem" size="8">\n'
|
|
r += " <tr><td style=\"font-weight:bold\">OS Type<td>%s\n" % (self._ostype_select())
|
|
r += ' <tr><td style="font-weight:bold">Disk Source<td><select name="disk_source" onChange="diskSourceSelected(this);">'
|
|
r += '<option value="create_new">Create New</option>'
|
|
if img_id:
|
|
r += '<option value="use_image" selected="true">Use Image</option>'
|
|
else:
|
|
r += '<option value="use_image">Use Image</option>'
|
|
r += '<option value="upload_file">Upload File</option>'
|
|
r += '<option value="server_file">Server File</option>'
|
|
r += '<option value="remote_url">Remote URL</option>'
|
|
r += '</select>\n'
|
|
if img_id:
|
|
r += ' <tr style="display:none" id="disk_size_row"><td style="font-weight:bold">Disk Size<td><input type="text" name="disk_size" size="8">\n'
|
|
r += " <tr style=\"display:table-row\" id=\"disk_image_row\"><td style=\"font-weight:bold\">Disk Image<td>%s\n" % (self._disk_image_select(img_id))
|
|
else:
|
|
r += ' <tr style="display:table-row" id="disk_size_row"><td style="font-weight:bold">Disk Size<td><input type="text" name="disk_size" size="8">\n'
|
|
r += " <tr style=\"display:none\" id=\"disk_image_row\"><td style=\"font-weight:bold\">Disk Image<td>%s\n" % (self._disk_image_select(img_id))
|
|
r += ' <tr style="display:none" id="disk_upload_row"><td style="font-weight:bold">Upload File<td><input type="file" name="upload_file">\n'
|
|
r += ' <tr style="display:none" id="disk_local_row"><td style="font-weight:bold">Server File<td><input type="text" name="server_file" size="32">\n'
|
|
r += ' <tr style="display:none" id="disk_remote_row"><td style="font-weight:bold">Remote URL<td><input type="text" name="remote_url" size="32">\n'
|
|
r += ' <tr><td colspan="2" align="center"><input type="submit" name="action" value="Create">\n'
|
|
r += ' </table>\n'
|
|
r += ' </form>\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 += ' <p>Exception handling request</p>\n'
|
|
r += " <pre>%s</pre>\n" % (e)
|
|
if log_debug: # XXX: opts['debug']
|
|
r += ' <p>Backtrace</p>\n'
|
|
r += ' <pre>\n'
|
|
for line in traceback.format_tb(e.__traceback__):
|
|
r += "%s\n" % (line)
|
|
r += ' </pre>\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()
|