#!/usr/bin/python """Main CGI script for web interface""" import base64 import cPickle import cgi import datetime import hmac import random import sha import simplejson import sys import time import urllib import socket from StringIO import StringIO def revertStandardError(): """Move stderr to stdout, and return the contents of the old stderr.""" errio = sys.stderr if not isinstance(errio, StringIO): return '' sys.stderr = sys.stdout errio.seek(0) return errio.read() def printError(): """Revert stderr to stdout, and print the contents of stderr""" if isinstance(sys.stderr, StringIO): print revertStandardError() if __name__ == '__main__': import atexit atexit.register(printError) import templates from Cheetah.Template import Template import validation import cache_acls from webcommon import State import controls from getafsgroups import getAfsGroupMembers from invirt import database from invirt.database import Machine, CDROM, session, connect, MachineAccess, Type, Autoinstall from invirt.config import structs as config from invirt.common import InvalidInput, CodeError def pathSplit(path): if path.startswith('/'): path = path[1:] i = path.find('/') if i == -1: i = len(path) return path[:i], path[i:] class Checkpoint: def __init__(self): self.start_time = time.time() self.checkpoints = [] def checkpoint(self, s): self.checkpoints.append((s, time.time())) def __str__(self): return ('Timing info:\n%s\n' % '\n'.join(['%s: %s' % (d, t - self.start_time) for (d, t) in self.checkpoints])) checkpoint = Checkpoint() def jquote(string): return "'" + string.replace('\\', '\\\\').replace("'", "\\'").replace('\n', '\\n') + "'" def helppopup(subj): """Return HTML code for a (?) link to a specified help topic""" return ('(?)') def makeErrorPre(old, addition): if addition is None: return if old: return old[:-6] + '\n----\n' + str(addition) + '' else: return '
STDERR:
' + str(addition) + '' Template.database = database Template.config = config Template.helppopup = staticmethod(helppopup) Template.err = None class JsonDict: """Class to store a dictionary that will be converted to JSON""" def __init__(self, **kws): self.data = kws if 'err' in kws: err = kws['err'] del kws['err'] self.addError(err) def __str__(self): return simplejson.dumps(self.data) def addError(self, text): """Add stderr text to be displayed on the website.""" self.data['err'] = \ makeErrorPre(self.data.get('err'), text) class Defaults: """Class to store default values for fields.""" memory = 256 disk = 4.0 cdrom = '' autoinstall = '' name = '' description = '' type = 'linux-hvm' def __init__(self, max_memory=None, max_disk=None, **kws): if max_memory is not None: self.memory = min(self.memory, max_memory) if max_disk is not None: self.disk = min(self.disk, max_disk) for key in kws: setattr(self, key, kws[key]) DEFAULT_HEADERS = {'Content-Type': 'text/html'} def invalidInput(op, username, fields, err, emsg): """Print an error page when an InvalidInput exception occurs""" d = dict(op=op, user=username, err_field=err.err_field, err_value=str(err.err_value), stderr=emsg, errorMessage=str(err)) return templates.invalid(searchList=[d]) def hasVnc(status): """Does the machine with a given status list support VNC?""" if status is None: return False for l in status: if l[0] == 'device' and l[1][0] == 'vfb': d = dict(l[1][1:]) return 'location' in d return False def parseCreate(username, state, fields): kws = dict([(kw, fields.getfirst(kw)) for kw in 'name description owner memory disksize vmtype cdrom autoinstall'.split()]) validate = validation.Validate(username, state, strict=True, **kws) return dict(contact=username, name=validate.name, description=validate.description, memory=validate.memory, disksize=validate.disksize, owner=validate.owner, machine_type=validate.vmtype, cdrom=getattr(validate, 'cdrom', None), autoinstall=getattr(validate, 'autoinstall', None)) def create(username, state, path, fields): """Handler for create requests.""" try: parsed_fields = parseCreate(username, state, fields) machine = controls.createVm(username, state, **parsed_fields) except InvalidInput, err: pass else: err = None state.clear() #Changed global state d = getListDict(username, state) d['err'] = err if err: for field in fields.keys(): setattr(d['defaults'], field, fields.getfirst(field)) else: d['new_machine'] = parsed_fields['name'] return templates.list(searchList=[d]) def getListDict(username, state): """Gets the list of local variables used by list.tmpl.""" checkpoint.checkpoint('Starting') machines = state.machines checkpoint.checkpoint('Got my machines') on = {} has_vnc = {} xmlist = state.xmlist checkpoint.checkpoint('Got uptimes') can_clone = 'ice3' not in state.xmlist_raw for m in machines: if m not in xmlist: has_vnc[m] = 'Off' m.uptime = None else: m.uptime = xmlist[m]['uptime'] if xmlist[m]['console']: has_vnc[m] = True elif m.type.hvm: has_vnc[m] = "WTF?" else: has_vnc[m] = "ParaVM"+helppopup("ParaVM Console") max_memory = validation.maxMemory(username, state) max_disk = validation.maxDisk(username) checkpoint.checkpoint('Got max mem/disk') defaults = Defaults(max_memory=max_memory, max_disk=max_disk, owner=username) checkpoint.checkpoint('Got defaults') def sortkey(machine): return (machine.owner != username, machine.owner, machine.name) machines = sorted(machines, key=sortkey) d = dict(user=username, cant_add_vm=validation.cantAddVm(username, state), max_memory=max_memory, max_disk=max_disk, defaults=defaults, machines=machines, has_vnc=has_vnc, can_clone=can_clone) return d def listVms(username, state, path, fields): """Handler for list requests.""" checkpoint.checkpoint('Getting list dict') d = getListDict(username, state) checkpoint.checkpoint('Got list dict') return templates.list(searchList=[d]) def vnc(username, state, path, fields): """VNC applet page. Note that due to same-domain restrictions, the applet connects to the webserver, which needs to forward those requests to the xen server. The Xen server runs another proxy that (1) authenticates and (2) finds the correct port for the VM. You might want iptables like: -t nat -A PREROUTING -s ! 18.181.0.60 -i eth1 -p tcp -m tcp \ --dport 10003 -j DNAT --to-destination 18.181.0.60:10003 -t nat -A POSTROUTING -d 18.181.0.60 -o eth1 -p tcp -m tcp \ --dport 10003 -j SNAT --to-source 18.187.7.142 -A FORWARD -d 18.181.0.60 -i eth1 -o eth1 -p tcp -m tcp \ --dport 10003 -j ACCEPT Remember to enable iptables! echo 1 > /proc/sys/net/ipv4/ip_forward """ machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine token = controls.vnctoken(machine) host = controls.listHost(machine) if host: port = 10003 + [h.hostname for h in config.hosts].index(host) else: port = 5900 # dummy status = controls.statusInfo(machine) has_vnc = hasVnc(status) d = dict(user=username, on=status, has_vnc=has_vnc, machine=machine, hostname=state.environ.get('SERVER_NAME', 'localhost'), port=port, authtoken=token) return templates.vnc(searchList=[d]) def getHostname(nic): """Find the hostname associated with a NIC. XXX this should be merged with the similar logic in DNS and DHCP. """ if nic.hostname: hostname = nic.hostname elif nic.machine: hostname = nic.machine.name else: return None if '.' in hostname: return hostname else: return hostname + '.' + config.dns.domains[0] def getNicInfo(data_dict, machine): """Helper function for info, get data on nics for a machine. Modifies data_dict to include the relevant data, and returns a list of (key, name) pairs to display "name: data_dict[key]" to the user. """ data_dict['num_nics'] = len(machine.nics) nic_fields_template = [('nic%s_hostname', 'NIC %s Hostname'), ('nic%s_mac', 'NIC %s MAC Addr'), ('nic%s_ip', 'NIC %s IP'), ] nic_fields = [] for i in range(len(machine.nics)): nic_fields.extend([(x % i, y % i) for x, y in nic_fields_template]) data_dict['nic%s_hostname' % i] = getHostname(machine.nics[i]) data_dict['nic%s_mac' % i] = machine.nics[i].mac_addr data_dict['nic%s_ip' % i] = machine.nics[i].ip if len(machine.nics) == 1: nic_fields = [(x, y.replace('NIC 0 ', '')) for x, y in nic_fields] return nic_fields def getDiskInfo(data_dict, machine): """Helper function for info, get data on disks for a machine. Modifies data_dict to include the relevant data, and returns a list of (key, name) pairs to display "name: data_dict[key]" to the user. """ data_dict['num_disks'] = len(machine.disks) disk_fields_template = [('%s_size', '%s size')] disk_fields = [] for disk in machine.disks: name = disk.guest_device_name disk_fields.extend([(x % name, y % name) for x, y in disk_fields_template]) data_dict['%s_size' % name] = "%0.1f GiB" % (disk.size / 1024.) return disk_fields def command(username, state, path, fields): """Handler for running commands like boot and delete on a VM.""" back = fields.getfirst('back') try: d = controls.commandResult(username, state, fields) if d['command'] == 'Delete VM': back = 'list' except InvalidInput, err: if not back: raise print >> sys.stderr, err result = err else: result = 'Success!' if not back: return templates.command(searchList=[d]) if back == 'list': state.clear() #Changed global state d = getListDict(username, state) d['result'] = result return templates.list(searchList=[d]) elif back == 'info': machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine return ({'Status': '303 See Other', 'Location': 'info?machine_id=%d' % machine.machine_id}, "You shouldn't see this message.") else: raise InvalidInput('back', back, 'Not a known back page.') def modifyDict(username, state, fields): """Modify a machine as specified by CGI arguments. Return a list of local variables for modify.tmpl. """ olddisk = {} session.begin() try: kws = dict([(kw, fields.getfirst(kw)) for kw in 'machine_id owner admin contact name description memory vmtype disksize'.split()]) validate = validation.Validate(username, state, **kws) machine = validate.machine oldname = machine.name if hasattr(validate, 'memory'): machine.memory = validate.memory if hasattr(validate, 'vmtype'): machine.type = validate.vmtype if hasattr(validate, 'disksize'): disksize = validate.disksize disk = machine.disks[0] if disk.size != disksize: olddisk[disk.guest_device_name] = disksize disk.size = disksize session.save_or_update(disk) update_acl = False if hasattr(validate, 'owner') and validate.owner != machine.owner: machine.owner = validate.owner update_acl = True if hasattr(validate, 'name'): machine.name = validate.name for n in machine.nics: if n.hostname == oldname: n.hostname = validate.name if hasattr(validate, 'description'): machine.description = validate.description if hasattr(validate, 'admin') and validate.admin != machine.administrator: machine.administrator = validate.admin update_acl = True if hasattr(validate, 'contact'): machine.contact = validate.contact session.save_or_update(machine) if update_acl: cache_acls.refreshMachine(machine) session.commit() except: session.rollback() raise for diskname in olddisk: controls.resizeDisk(oldname, diskname, str(olddisk[diskname])) if hasattr(validate, 'name'): controls.renameMachine(machine, oldname, validate.name) return dict(user=username, command="modify", machine=machine) def modify(username, state, path, fields): """Handler for modifying attributes of a machine.""" try: modify_dict = modifyDict(username, state, fields) except InvalidInput, err: result = None machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine else: machine = modify_dict['machine'] result = 'Success!' err = None info_dict = infoDict(username, state, machine) info_dict['err'] = err if err: for field in fields.keys(): setattr(info_dict['defaults'], field, fields.getfirst(field)) info_dict['result'] = result return templates.info(searchList=[info_dict]) def helpHandler(username, state, path, fields): """Handler for help messages.""" simple = fields.getfirst('simple') subjects = fields.getlist('subject') help_mapping = { 'Autoinstalls': """ The autoinstaller builds a minimal Debian or Ubuntu system to run as a ParaVM. You can access the resulting system by logging into the serial console server with your Kerberos tickets; there is no root password so sshd will refuse login.
Under the covers, the autoinstaller uses our own patched version of xen-create-image, which is a tool based on debootstrap. If you log into the serial console while the install is running, you can watch it. """, 'ParaVM Console': """ ParaVM machines do not support local console access over VNC. To access the serial console of these machines, you can SSH with Kerberos to %s, using the name of the machine as your username.""" % config.console.hostname, 'HVM/ParaVM': """ HVM machines use the virtualization features of the processor, while ParaVM machines rely on a modified kernel to communicate directly with the hypervisor. HVMs support boot CDs of any operating system, and the VNC console applet. The three-minute autoinstaller produces ParaVMs. ParaVMs typically are more efficient, and always support the console server.
More details are on the wiki, including steps to prepare an HVM guest to boot as a ParaVM (which you can skip by using the autoinstaller to begin with.)
We recommend using a ParaVM when possible and an HVM when necessary.
""",
'CPU Weight': """
Don't ask us! We're as mystified as you are.""",
'Owner': """
The owner field is used to determine quotas. It must be the name of a
locker that you are an AFS administrator of. In particular, you or an
AFS group you are a member of must have AFS rlidwka bits on the
locker. You can check who administers the LOCKER locker using the
commands 'attach LOCKER; fs la /mit/LOCKER' on Athena.) See also administrator.""",
'Administrator': """
The administrator field determines who can access the console and
power on and off the machine. This can be either a user or a moira
group.""",
'Quotas': """
Quotas are determined on a per-locker basis. Each locker may have a
maximum of 512 mebibytes of active ram, 50 gibibytes of disk, and 4
active machines.""",
'Console': """
Framebuffer: At a Linux boot prompt in your VM, try
setting fb=false to disable the framebuffer. If you don't,
your machine will run just fine, but the applet's display of the
console will suffer artifacts.
""",
'Windows': """
Windows Vista: The Vista image is licensed for all MIT students and will automatically activate off the network; see the licensing confirmation e-mail for details. The installer requires 512 MiB RAM and at least 7.5 GiB disk space (15 GiB or more recommended).
Windows XP: This is the volume license CD image. You will need your own volume license key to complete the install. We do not have these available for the general MIT community; ask your department if they have one.
"""
}
if not subjects:
subjects = sorted(help_mapping.keys())
d = dict(user=username,
simple=simple,
subjects=subjects,
mapping=help_mapping)
return templates.help(searchList=[d])
def badOperation(u, s, p, e):
"""Function called when accessing an unknown URI."""
return ({'Status': '404 Not Found'}, 'Invalid operation.')
def infoDict(username, state, machine):
"""Get the variables used by info.tmpl."""
status = controls.statusInfo(machine)
checkpoint.checkpoint('Getting status info')
has_vnc = hasVnc(status)
if status is None:
main_status = dict(name=machine.name,
memory=str(machine.memory))
uptime = None
cputime = None
else:
main_status = dict(status[1:])
main_status['host'] = controls.listHost(machine)
start_time = float(main_status.get('start_time', 0))
uptime = datetime.timedelta(seconds=int(time.time()-start_time))
cpu_time_float = float(main_status.get('cpu_time', 0))
cputime = datetime.timedelta(seconds=int(cpu_time_float))
checkpoint.checkpoint('Status')
display_fields = [('name', 'Name'),
('description', 'Description'),
('owner', 'Owner'),
('administrator', 'Administrator'),
('contact', 'Contact'),
('type', 'Type'),
'NIC_INFO',
('uptime', 'uptime'),
('cputime', 'CPU usage'),
('host', 'Hosted on'),
('memory', 'RAM'),
'DISK_INFO',
('state', 'state (xen format)'),
('cpu_weight', 'CPU weight'+helppopup('CPU Weight')),
]
fields = []
machine_info = {}
machine_info['name'] = machine.name
machine_info['description'] = machine.description
machine_info['type'] = machine.type.hvm and 'HVM' or 'ParaVM'
machine_info['owner'] = machine.owner
machine_info['administrator'] = machine.administrator
machine_info['contact'] = machine.contact
nic_fields = getNicInfo(machine_info, machine)
nic_point = display_fields.index('NIC_INFO')
display_fields = (display_fields[:nic_point] + nic_fields +
display_fields[nic_point+1:])
disk_fields = getDiskInfo(machine_info, machine)
disk_point = display_fields.index('DISK_INFO')
display_fields = (display_fields[:disk_point] + disk_fields +
display_fields[disk_point+1:])
main_status['memory'] += ' MiB'
for field, disp in display_fields:
if field in ('uptime', 'cputime') and locals()[field] is not None:
fields.append((disp, locals()[field]))
elif field in machine_info:
fields.append((disp, machine_info[field]))
elif field in main_status:
fields.append((disp, main_status[field]))
else:
pass
#fields.append((disp, None))
checkpoint.checkpoint('Got fields')
max_mem = validation.maxMemory(machine.owner, state, machine, False)
checkpoint.checkpoint('Got mem')
max_disk = validation.maxDisk(machine.owner, machine)
defaults = Defaults()
for name in 'machine_id name description administrator owner memory contact'.split():
setattr(defaults, name, getattr(machine, name))
defaults.type = machine.type.type_id
defaults.disk = "%0.2f" % (machine.disks[0].size/1024.)
checkpoint.checkpoint('Got defaults')
d = dict(user=username,
on=status is not None,
machine=machine,
defaults=defaults,
has_vnc=has_vnc,
uptime=str(uptime),
ram=machine.memory,
max_mem=max_mem,
max_disk=max_disk,
owner_help=helppopup("Owner"),
fields = fields)
return d
def info(username, state, path, fields):
"""Handler for info on a single VM."""
machine = validation.Validate(username, state, machine_id=fields.getfirst('machine_id')).machine
d = infoDict(username, state, machine)
checkpoint.checkpoint('Got infodict')
return templates.info(searchList=[d])
def unauthFront(_, _2, _3, fields):
"""Information for unauth'd users."""
return templates.unauth(searchList=[{'simple' : True,
'hostname' : socket.getfqdn()}])
def admin(username, state, path, fields):
if path == '':
return ({'Status': '303 See Other',
'Location': 'admin/'},
"You shouldn't see this message.")
if not username in getAfsGroupMembers(config.web.adminacl, 'athena.mit.edu'):
raise InvalidInput('username', username,
'Not in admin group %s.' % config.web.adminacl)
newstate = State(username, isadmin=True)
newstate.environ = state.environ
return handler(username, newstate, path, fields)
def throwError(_, __, ___, ____):
"""Throw an error, to test the error-tracing mechanisms."""
raise RuntimeError("test of the emergency broadcast system")
mapping = dict(list=listVms,
vnc=vnc,
command=command,
modify=modify,
info=info,
create=create,
help=helpHandler,
unauth=unauthFront,
admin=admin,
overlord=admin,
errortest=throwError)
def printHeaders(headers):
"""Print a dictionary as HTTP headers."""
for key, value in headers.iteritems():
print '%s: %s' % (key, value)
print
def send_error_mail(subject, body):
import subprocess
to = config.web.errormail
mail = """To: %s
From: root@%s
Subject: %s
%s
""" % (to, config.web.hostname, subject, body)
p = subprocess.Popen(['/usr/sbin/sendmail', '-f', to, to],
stdin=subprocess.PIPE)
p.stdin.write(mail)
p.stdin.close()
p.wait()
def show_error(op, username, fields, err, emsg, traceback):
"""Print an error page when an exception occurs"""
d = dict(op=op, user=username, fields=fields,
errorMessage=str(err), stderr=emsg, traceback=traceback)
details = templates.error_raw(searchList=[d])
exclude = config.web.errormail_exclude
if username not in exclude and '*' not in exclude:
send_error_mail('xvm error on %s for %s: %s' % (op, username, err),
details)
d['details'] = details
return templates.error(searchList=[d])
def getUser(environ):
"""Return the current user based on the SSL environment variables"""
user = environ.get('REMOTE_USER')
if user is None:
return
if environ.get('AUTH_TYPE') == 'Negotiate':
# Convert the krb5 principal into a krb4 username
if not user.endswith('@%s' % config.kerberos.realm):
return
else:
return user.split('@')[0].replace('/', '.')
else:
return user
def handler(username, state, path, fields):
operation, path = pathSplit(path)
if not operation:
operation = 'list'
print 'Starting', operation
fun = mapping.get(operation, badOperation)
return fun(username, state, path, fields)
class App:
def __init__(self, environ, start_response):
self.environ = environ
self.start = start_response
self.username = getUser(environ)
self.state = State(self.username)
self.state.environ = environ
random.seed() #sigh
def __iter__(self):
start_time = time.time()
database.clear_cache()
sys.stderr = StringIO()
fields = cgi.FieldStorage(fp=self.environ['wsgi.input'], environ=self.environ)
operation = self.environ.get('PATH_INFO', '')
if not operation:
self.start("301 Moved Permanently", [('Location', './')])
return
if self.username is None:
operation = 'unauth'
try:
checkpoint.checkpoint('Before')
output = handler(self.username, self.state, operation, fields)
checkpoint.checkpoint('After')
headers = dict(DEFAULT_HEADERS)
if isinstance(output, tuple):
new_headers, output = output
headers.update(new_headers)
e = revertStandardError()
if e:
if hasattr(output, 'addError'):
output.addError(e)
else:
# This only happens on redirects, so it'd be a pain to get
# the message to the user. Maybe in the response is useful.
output = output + '\n\nstderr:\n' + e
output_string = str(output)
checkpoint.checkpoint('output as a string')
except Exception, err:
if not fields.has_key('js'):
if isinstance(err, InvalidInput):
self.start('200 OK', [('Content-Type', 'text/html')])
e = revertStandardError()
yield str(invalidInput(operation, self.username, fields,
err, e))
return
import traceback
self.start('500 Internal Server Error',
[('Content-Type', 'text/html')])
e = revertStandardError()
s = show_error(operation, self.username, fields,
err, e, traceback.format_exc())
yield str(s)
return
status = headers.setdefault('Status', '200 OK')
del headers['Status']
self.start(status, headers.items())
yield output_string
if fields.has_key('timedebug'):
yield '
%s' % cgi.escape(str(checkpoint)) def constructor(): connect() return App def main(): from flup.server.fcgi_fork import WSGIServer WSGIServer(constructor()).run() if __name__ == '__main__': main()