#!/usr/bin/python """Main CGI script for web interface""" import base64 import cPickle import cgi import datetime import hmac import os import random import sha import sys import time import urllib import socket import cherrypy from cherrypy import _cperror from StringIO import StringIO 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 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 from view import View, revertStandardError static_dir = os.path.join(os.path.dirname(__file__), 'static') InvirtStatic = cherrypy.tools.staticdir.handler( root=static_dir, dir=static_dir, section='/static') class InvirtUnauthWeb(View): static = InvirtStatic @cherrypy.expose @cherrypy.tools.mako(filename="/unauth.mako") def index(self): return dict(simple=True) class InvirtWeb(View): def __init__(self): super(self.__class__,self).__init__() connect() self._cp_config['tools.require_login.on'] = True self._cp_config['tools.catch_stderr.on'] = True self._cp_config['tools.mako.imports'] = ['from invirt.config import structs as config', 'from invirt import database'] self._cp_config['request.error_response'] = self.handle_error static = InvirtStatic @cherrypy.expose @cherrypy.tools.mako(filename="/invalid.mako") def invalidInput(self): """Print an error page when an InvalidInput exception occurs""" err = cherrypy.request.prev.params["err"] emsg = cherrypy.request.prev.params["emsg"] d = dict(err_field=err.err_field, err_value=str(err.err_value), stderr=emsg, errorMessage=str(err)) return d @cherrypy.expose @cherrypy.tools.mako(filename="/error.mako") def error(self): """Print an error page when an exception occurs""" op = cherrypy.request.prev.path_info username = cherrypy.request.login err = cherrypy.request.prev.params["err"] emsg = cherrypy.request.prev.params["emsg"] traceback = cherrypy.request.prev.params["traceback"] d = dict(op=op, user=username, fields=cherrypy.request.prev.params, errorMessage=str(err), stderr=emsg, traceback=traceback) error_raw = cherrypy.request.lookup.get_template("/error_raw.mako") details = error_raw.render(**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, cherrypy.request.login, err), details) d['details'] = details return d def __getattr__(self, name): if name in ("admin", "overlord"): if not cherrypy.request.login in getAfsGroupMembers(config.adminacl, config.authz.afs.cells[0].cell): raise InvalidInput('username', cherrypy.request.login, 'Not in admin group %s.' % config.adminacl) cherrypy.request.state = State(cherrypy.request.login, isadmin=True) return self else: return super(InvirtWeb, self).__getattr__(name) def handle_error(self): err = sys.exc_info()[1] if isinstance(err, InvalidInput): cherrypy.request.params['err'] = err cherrypy.request.params['emsg'] = revertStandardError() raise cherrypy.InternalRedirect('/invalidInput') if not cherrypy.request.prev or 'err' not in cherrypy.request.prev.params: cherrypy.request.params['err'] = err cherrypy.request.params['emsg'] = revertStandardError() cherrypy.request.params['traceback'] = _cperror.format_exc() raise cherrypy.InternalRedirect('/error') # fall back to cherrypy default error page cherrypy.HTTPError(500).set_response() @cherrypy.expose @cherrypy.tools.mako(filename="/list.mako") def list(self, result=None): """Handler for list requests.""" d = getListDict(cherrypy.request.login, cherrypy.request.state) if result is not None: d['result'] = result return d index=list @cherrypy.expose @cherrypy.tools.mako(filename="/help.mako") def help(self, subject=None, simple=False): """Handler for help messages.""" 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, or visit http://msca.mit.edu/ if you are staff/faculty to request one.
"""
}
if not subject:
subject = sorted(help_mapping.keys())
if not isinstance(subject, list):
subject = [subject]
return dict(simple=simple,
subjects=subject,
mapping=help_mapping)
help._cp_config['tools.require_login.on'] = False
def parseCreate(self, fields):
kws = dict([(kw, fields[kw]) for kw in
'name description owner memory disksize vmtype cdrom autoinstall'.split()
if fields[kw]])
validate = validation.Validate(cherrypy.request.login,
cherrypy.request.state,
strict=True, **kws)
return dict(contact=cherrypy.request.login, name=validate.name,
description=validate.description, memory=validate.memory,
disksize=validate.disksize, owner=validate.owner,
machine_type=getattr(validate, 'vmtype', Defaults.type),
cdrom=getattr(validate, 'cdrom', None),
autoinstall=getattr(validate, 'autoinstall', None))
@cherrypy.expose
@cherrypy.tools.mako(filename="/list.mako")
@cherrypy.tools.require_POST()
def create(self, **fields):
"""Handler for create requests."""
try:
parsed_fields = self.parseCreate(fields)
machine = controls.createVm(cherrypy.request.login,
cherrypy.request.state, **parsed_fields)
except InvalidInput, err:
pass
else:
err = None
cherrypy.request.state.clear() #Changed global state
d = getListDict(cherrypy.request.login, cherrypy.request.state)
d['err'] = err
if err:
for field, value in fields.items():
setattr(d['defaults'], field, value)
else:
d['new_machine'] = parsed_fields['name']
return d
@cherrypy.expose
@cherrypy.tools.mako(filename="/helloworld.mako")
def helloworld(self, **kwargs):
return {'request': cherrypy.request, 'kwargs': kwargs}
helloworld._cp_config['tools.require_login.on'] = False
@cherrypy.expose
def errortest(self):
"""Throw an error, to test the error-tracing mechanisms."""
print >>sys.stderr, "look ma, it's a stderr"
raise RuntimeError("test of the emergency broadcast system")
class MachineView(View):
def __getattr__(self, name):
"""Synthesize attributes to allow RESTful URLs like
/machine/13/info. This is hairy. CherryPy 3.2 adds a
method called _cp_dispatch that allows you to explicitly
handle URLs that can't be mapped, and it allows you to
rewrite the path components and continue processing.
This function gets the next path component being resolved
as a string. _cp_dispatch will get an array of strings
representing any subsequent path components as well."""
try:
cherrypy.request.params['machine_id'] = int(name)
return self
except ValueError:
return None
@cherrypy.expose
@cherrypy.tools.mako(filename="/info.mako")
def info(self, machine_id):
"""Handler for info on a single VM."""
machine = validation.Validate(cherrypy.request.login,
cherrypy.request.state,
machine_id=machine_id).machine
d = infoDict(cherrypy.request.login, cherrypy.request.state, machine)
return d
index = info
@cherrypy.expose
@cherrypy.tools.mako(filename="/info.mako")
@cherrypy.tools.require_POST()
def modify(self, machine_id, **fields):
"""Handler for modifying attributes of a machine."""
try:
modify_dict = modifyDict(cherrypy.request.login,
cherrypy.request.state,
machine_id, fields)
except InvalidInput, err:
result = None
machine = validation.Validate(cherrypy.request.login,
cherrypy.request.state,
machine_id=machine_id).machine
else:
machine = modify_dict['machine']
result = 'Success!'
err = None
info_dict = infoDict(cherrypy.request.login,
cherrypy.request.state, machine)
info_dict['err'] = err
if err:
for field, value in fields.items():
setattr(info_dict['defaults'], field, value)
info_dict['result'] = result
return info_dict
@cherrypy.expose
@cherrypy.tools.mako(filename="/vnc.mako")
def vnc(self, machine_id):
"""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(cherrypy.request.login,
cherrypy.request.state,
machine_id=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(on=status,
has_vnc=has_vnc,
machine=machine,
hostname=cherrypy.request.local.name,
port=port,
authtoken=token)
return d
@cherrypy.expose
@cherrypy.tools.mako(filename="/command.mako")
@cherrypy.tools.require_POST()
def command(self, command_name, machine_id, **kwargs):
"""Handler for running commands like boot and delete on a VM."""
back = kwargs.get('back')
if command_name == 'delete':
back = 'list'
try:
d = controls.commandResult(cherrypy.request.login,
cherrypy.request.state,
command_name, machine_id, kwargs)
except InvalidInput, err:
if not back:
raise
print >> sys.stderr, err
result = str(err)
else:
result = 'Success!'
if not back:
return d
if back == 'list':
cherrypy.request.state.clear() #Changed global state
raise cherrypy.InternalRedirect('/list?result=%s'
% urllib.quote(result))
elif back == 'info':
raise cherrypy.HTTPRedirect(cherrypy.request.base
+ '/machine/%d/' % machine_id,
status=303)
else:
raise InvalidInput('back', back, 'Not a known back page.')
machine = MachineView()
class Defaults:
"""Class to store default values for fields."""
memory = 256
disk = 4.0
cdrom = ''
autoinstall = ''
name = ''
description = ''
administrator = ''
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])
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 getListDict(username, state):
"""Gets the list of local variables used by list.tmpl."""
machines = state.machines
on = {}
has_vnc = {}
installing = {}
xmlist = state.xmlist
for m in machines:
if m not in xmlist:
has_vnc[m] = 'Off'
m.uptime = None
else:
m.uptime = xmlist[m]['uptime']
installing[m] = bool(xmlist[m].get('autoinstall'))
if xmlist[m]['console']:
has_vnc[m] = True
elif m.type.hvm:
has_vnc[m] = "WTF?"
else:
has_vnc[m] = "ParaVM"
max_memory = validation.maxMemory(username, state)
max_disk = validation.maxDisk(username)
defaults = Defaults(max_memory=max_memory,
max_disk=max_disk,
owner=username)
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,
installing=installing)
return 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 modifyDict(username, state, machine_id, fields):
"""Modify a machine as specified by CGI arguments.
Return a dict containing the machine that was modified.
"""
olddisk = {}
session.begin()
try:
kws = dict((kw, fields[kw]) for kw in
'owner admin contact name description memory vmtype disksize'.split()
if fields.get(kw))
kws['machine_id'] = machine_id
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(machine=machine)
def infoDict(username, state, machine):
"""Get the variables used by info.tmpl."""
status = controls.statusInfo(machine)
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))
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)'),
]
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))
max_mem = validation.maxMemory(machine.owner, state, machine, False)
max_disk = validation.maxDisk(machine.owner, machine)
defaults = Defaults()
for name in 'machine_id name description administrator owner memory contact'.split():
if getattr(machine, name):
setattr(defaults, name, getattr(machine, name))
defaults.type = machine.type.type_id
defaults.disk = "%0.2f" % (machine.disks[0].size/1024.)
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,
fields = fields)
return d
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()
random.seed() #sigh