source: trunk/packages/sipb-xen-www/code/main.py @ 485

Last change on this file since 485 was 447, checked in by ecprice, 17 years ago

Avoid html injection.

Cheetah is painful.

  • Property svn:executable set to *
File size: 23.4 KB
Line 
1#!/usr/bin/python
2"""Main CGI script for web interface"""
3
4import base64
5import cPickle
6import cgi
7import datetime
8import hmac
9import os
10import sha
11import simplejson
12import sys
13import time
14import urllib
15from StringIO import StringIO
16
17def revertStandardError():
18    """Move stderr to stdout, and return the contents of the old stderr."""
19    errio = sys.stderr
20    if not isinstance(errio, StringIO):
21        return None
22    sys.stderr = sys.stdout
23    errio.seek(0)
24    return errio.read()
25
26def printError():
27    """Revert stderr to stdout, and print the contents of stderr"""
28    if isinstance(sys.stderr, StringIO):
29        print revertStandardError()
30
31if __name__ == '__main__':
32    import atexit
33    atexit.register(printError)
34    sys.stderr = StringIO()
35
36sys.path.append('/home/ecprice/.local/lib/python2.5/site-packages')
37
38import templates
39from Cheetah.Template import Template
40import sipb_xen_database
41from sipb_xen_database import Machine, CDROM, ctx, connect, MachineAccess, Type, Autoinstall
42import validation
43import cache_acls
44from webcommon import InvalidInput, CodeError, g
45import controls
46
47class Checkpoint:
48    def __init__(self):
49        self.start_time = time.time()
50        self.checkpoints = []
51
52    def checkpoint(self, s):
53        self.checkpoints.append((s, time.time()))
54
55    def __str__(self):
56        return ('Timing info:\n%s\n' %
57                '\n'.join(['%s: %s' % (d, t - self.start_time) for
58                           (d, t) in self.checkpoints]))
59
60checkpoint = Checkpoint()
61
62def jquote(string):
63    return "'" + string.replace('\\', '\\\\').replace("'", "\\'").replace('\n', '\\n') + "'"
64
65def helppopup(subj):
66    """Return HTML code for a (?) link to a specified help topic"""
67    return ('<span class="helplink"><a href="help?' +
68            cgi.escape(urllib.urlencode(dict(subject=subj, simple='true')))
69            +'" target="_blank" ' +
70            'onclick="return helppopup(' + cgi.escape(jquote(subj)) + ')">(?)</a></span>')
71
72def makeErrorPre(old, addition):
73    if addition is None:
74        return
75    if old:
76        return old[:-6]  + '\n----\n' + str(addition) + '</pre>'
77    else:
78        return '<p>STDERR:</p><pre>' + str(addition) + '</pre>'
79
80Template.sipb_xen_database = sipb_xen_database
81Template.helppopup = staticmethod(helppopup)
82Template.err = None
83
84class JsonDict:
85    """Class to store a dictionary that will be converted to JSON"""
86    def __init__(self, **kws):
87        self.data = kws
88        if 'err' in kws:
89            err = kws['err']
90            del kws['err']
91            self.addError(err)
92
93    def __str__(self):
94        return simplejson.dumps(self.data)
95
96    def addError(self, text):
97        """Add stderr text to be displayed on the website."""
98        self.data['err'] = \
99            makeErrorPre(self.data.get('err'), text)
100
101class Defaults:
102    """Class to store default values for fields."""
103    memory = 256
104    disk = 4.0
105    cdrom = ''
106    autoinstall = ''
107    name = ''
108    def __init__(self, max_memory=None, max_disk=None, **kws):
109        self.type = Type.get('linux-hvm')
110        if max_memory is not None:
111            self.memory = min(self.memory, max_memory)
112        if max_disk is not None:
113            self.max_disk = min(self.disk, max_disk)
114        for key in kws:
115            setattr(self, key, kws[key])
116
117
118
119DEFAULT_HEADERS = {'Content-Type': 'text/html'}
120
121def error(op, user, fields, err, emsg):
122    """Print an error page when a CodeError occurs"""
123    d = dict(op=op, user=user, errorMessage=str(err),
124             stderr=emsg)
125    return templates.error(searchList=[d])
126
127def invalidInput(op, user, fields, err, emsg):
128    """Print an error page when an InvalidInput exception occurs"""
129    d = dict(op=op, user=user, err_field=err.err_field,
130             err_value=str(err.err_value), stderr=emsg,
131             errorMessage=str(err))
132    return templates.invalid(searchList=[d])
133
134def hasVnc(status):
135    """Does the machine with a given status list support VNC?"""
136    if status is None:
137        return False
138    for l in status:
139        if l[0] == 'device' and l[1][0] == 'vfb':
140            d = dict(l[1][1:])
141            return 'location' in d
142    return False
143
144def parseCreate(user, fields):
145    name = fields.getfirst('name')
146    if not validation.validMachineName(name):
147        raise InvalidInput('name', name, 'You must provide a machine name.  Max 22 chars, alnum plus \'-\' and \'_\'.')
148    name = name.lower()
149
150    if Machine.get_by(name=name):
151        raise InvalidInput('name', name,
152                           "Name already exists.")
153
154    owner = validation.testOwner(user, fields.getfirst('owner'))
155
156    memory = fields.getfirst('memory')
157    memory = validation.validMemory(owner, memory, on=True)
158
159    disk_size = fields.getfirst('disk')
160    disk_size = validation.validDisk(owner, disk_size)
161
162    vm_type = fields.getfirst('vmtype')
163    vm_type = validation.validVmType(vm_type)
164
165    cdrom = fields.getfirst('cdrom')
166    if cdrom is not None and not CDROM.get(cdrom):
167        raise CodeError("Invalid cdrom type '%s'" % cdrom)
168
169    clone_from = fields.getfirst('clone_from')
170    if clone_from and clone_from != 'ice3':
171        raise CodeError("Invalid clone image '%s'" % clone_from)
172
173    return dict(contact=user, name=name, memory=memory, disk_size=disk_size,
174                owner=owner, machine_type=vm_type, cdrom=cdrom, clone_from=clone_from)
175
176def create(user, fields):
177    """Handler for create requests."""
178    try:
179        parsed_fields = parseCreate(user, fields)
180        machine = controls.createVm(**parsed_fields)
181    except InvalidInput, err:
182        pass
183    else:
184        err = None
185    g.clear() #Changed global state
186    d = getListDict(user)
187    d['err'] = err
188    if err:
189        for field in fields.keys():
190            setattr(d['defaults'], field, fields.getfirst(field))
191    else:
192        d['new_machine'] = parsed_fields['name']
193    return templates.list(searchList=[d])
194
195
196def getListDict(user):
197    """Gets the list of local variables used by list.tmpl."""
198    machines = g.machines
199    checkpoint.checkpoint('Got my machines')
200    on = {}
201    has_vnc = {}
202    on = g.uptimes
203    checkpoint.checkpoint('Got uptimes')
204    for m in machines:
205        m.uptime = g.uptimes.get(m)
206        if not on[m]:
207            has_vnc[m] = 'Off'
208        elif m.type.hvm:
209            has_vnc[m] = True
210        else:
211            has_vnc[m] = "ParaVM"+helppopup("paravm_console")
212    max_memory = validation.maxMemory(user)
213    max_disk = validation.maxDisk(user)
214    checkpoint.checkpoint('Got max mem/disk')
215    defaults = Defaults(max_memory=max_memory,
216                        max_disk=max_disk,
217                        owner=user,
218                        cdrom='gutsy-i386')
219    checkpoint.checkpoint('Got defaults')
220    def sortkey(machine):
221        return (machine.owner != user, machine.owner, machine.name)
222    machines = sorted(machines, key=sortkey)
223    d = dict(user=user,
224             cant_add_vm=validation.cantAddVm(user),
225             max_memory=max_memory,
226             max_disk=max_disk,
227             defaults=defaults,
228             machines=machines,
229             has_vnc=has_vnc,
230             uptimes=g.uptimes)
231    return d
232
233def listVms(user, fields):
234    """Handler for list requests."""
235    checkpoint.checkpoint('Getting list dict')
236    d = getListDict(user)
237    checkpoint.checkpoint('Got list dict')
238    return templates.list(searchList=[d])
239
240def vnc(user, fields):
241    """VNC applet page.
242
243    Note that due to same-domain restrictions, the applet connects to
244    the webserver, which needs to forward those requests to the xen
245    server.  The Xen server runs another proxy that (1) authenticates
246    and (2) finds the correct port for the VM.
247
248    You might want iptables like:
249
250    -t nat -A PREROUTING -s ! 18.181.0.60 -i eth1 -p tcp -m tcp \
251      --dport 10003 -j DNAT --to-destination 18.181.0.60:10003
252    -t nat -A POSTROUTING -d 18.181.0.60 -o eth1 -p tcp -m tcp \
253      --dport 10003 -j SNAT --to-source 18.187.7.142
254    -A FORWARD -d 18.181.0.60 -i eth1 -o eth1 -p tcp -m tcp \
255      --dport 10003 -j ACCEPT
256
257    Remember to enable iptables!
258    echo 1 > /proc/sys/net/ipv4/ip_forward
259    """
260    machine = validation.testMachineId(user, fields.getfirst('machine_id'))
261
262    TOKEN_KEY = "0M6W0U1IXexThi5idy8mnkqPKEq1LtEnlK/pZSn0cDrN"
263
264    data = {}
265    data["user"] = user
266    data["machine"] = machine.name
267    data["expires"] = time.time()+(5*60)
268    pickled_data = cPickle.dumps(data)
269    m = hmac.new(TOKEN_KEY, digestmod=sha)
270    m.update(pickled_data)
271    token = {'data': pickled_data, 'digest': m.digest()}
272    token = cPickle.dumps(token)
273    token = base64.urlsafe_b64encode(token)
274
275    status = controls.statusInfo(machine)
276    has_vnc = hasVnc(status)
277
278    d = dict(user=user,
279             on=status,
280             has_vnc=has_vnc,
281             machine=machine,
282             hostname=os.environ.get('SERVER_NAME', 'localhost'),
283             authtoken=token)
284    return templates.vnc(searchList=[d])
285
286def getHostname(nic):
287    """Find the hostname associated with a NIC.
288
289    XXX this should be merged with the similar logic in DNS and DHCP.
290    """
291    if nic.hostname and '.' in nic.hostname:
292        return nic.hostname
293    elif nic.machine:
294        return nic.machine.name + '.servers.csail.mit.edu'
295    else:
296        return None
297
298
299def getNicInfo(data_dict, machine):
300    """Helper function for info, get data on nics for a machine.
301
302    Modifies data_dict to include the relevant data, and returns a list
303    of (key, name) pairs to display "name: data_dict[key]" to the user.
304    """
305    data_dict['num_nics'] = len(machine.nics)
306    nic_fields_template = [('nic%s_hostname', 'NIC %s Hostname'),
307                           ('nic%s_mac', 'NIC %s MAC Addr'),
308                           ('nic%s_ip', 'NIC %s IP'),
309                           ]
310    nic_fields = []
311    for i in range(len(machine.nics)):
312        nic_fields.extend([(x % i, y % i) for x, y in nic_fields_template])
313        if not i:
314            data_dict['nic%s_hostname' % i] = getHostname(machine.nics[i])
315        data_dict['nic%s_mac' % i] = machine.nics[i].mac_addr
316        data_dict['nic%s_ip' % i] = machine.nics[i].ip
317    if len(machine.nics) == 1:
318        nic_fields = [(x, y.replace('NIC 0 ', '')) for x, y in nic_fields]
319    return nic_fields
320
321def getDiskInfo(data_dict, machine):
322    """Helper function for info, get data on disks for a machine.
323
324    Modifies data_dict to include the relevant data, and returns a list
325    of (key, name) pairs to display "name: data_dict[key]" to the user.
326    """
327    data_dict['num_disks'] = len(machine.disks)
328    disk_fields_template = [('%s_size', '%s size')]
329    disk_fields = []
330    for disk in machine.disks:
331        name = disk.guest_device_name
332        disk_fields.extend([(x % name, y % name) for x, y in
333                            disk_fields_template])
334        data_dict['%s_size' % name] = "%0.1f GiB" % (disk.size / 1024.)
335    return disk_fields
336
337def command(user, fields):
338    """Handler for running commands like boot and delete on a VM."""
339    back = fields.getfirst('back')
340    try:
341        d = controls.commandResult(user, fields)
342        if d['command'] == 'Delete VM':
343            back = 'list'
344    except InvalidInput, err:
345        if not back:
346            raise
347        #print >> sys.stderr, err
348        result = err
349    else:
350        result = 'Success!'
351        if not back:
352            return templates.command(searchList=[d])
353    if back == 'list':
354        g.clear() #Changed global state
355        d = getListDict(user)
356        d['result'] = result
357        return templates.list(searchList=[d])
358    elif back == 'info':
359        machine = validation.testMachineId(user, fields.getfirst('machine_id'))
360        return ({'Status': '302',
361                 'Location': '/info?machine_id=%d' % machine.machine_id},
362                "You shouldn't see this message.")
363    else:
364        raise InvalidInput('back', back, 'Not a known back page.')
365
366def modifyDict(user, fields):
367    """Modify a machine as specified by CGI arguments.
368
369    Return a list of local variables for modify.tmpl.
370    """
371    olddisk = {}
372    transaction = ctx.current.create_transaction()
373    try:
374        machine = validation.testMachineId(user, fields.getfirst('machine_id'))
375        owner = validation.testOwner(user, fields.getfirst('owner'), machine)
376        admin = validation.testAdmin(user, fields.getfirst('administrator'),
377                                     machine)
378        contact = validation.testContact(user, fields.getfirst('contact'),
379                                         machine)
380        name = validation.testName(user, fields.getfirst('name'), machine)
381        oldname = machine.name
382        command = "modify"
383
384        memory = fields.getfirst('memory')
385        if memory is not None:
386            memory = validation.validMemory(user, memory, machine, on=False)
387            machine.memory = memory
388
389        vm_type = validation.validVmType(fields.getfirst('vmtype'))
390        if vm_type is not None:
391            machine.type = vm_type
392
393        disksize = validation.testDisk(user, fields.getfirst('disk'))
394        if disksize is not None:
395            disksize = validation.validDisk(user, disksize, machine)
396            disk = machine.disks[0]
397            if disk.size != disksize:
398                olddisk[disk.guest_device_name] = disksize
399                disk.size = disksize
400                ctx.current.save(disk)
401
402        update_acl = False
403        if owner is not None and owner != machine.owner:
404            machine.owner = owner
405            update_acl = True
406        if name is not None:
407            machine.name = name
408        if admin is not None and admin != machine.administrator:
409            machine.administrator = admin
410            update_acl = True
411        if contact is not None:
412            machine.contact = contact
413
414        ctx.current.save(machine)
415        if update_acl:
416            cache_acls.refreshMachine(machine)
417        transaction.commit()
418    except:
419        transaction.rollback()
420        raise
421    for diskname in olddisk:
422        controls.resizeDisk(oldname, diskname, str(olddisk[diskname]))
423    if name is not None:
424        controls.renameMachine(machine, oldname, name)
425    return dict(user=user,
426                command=command,
427                machine=machine)
428
429def modify(user, fields):
430    """Handler for modifying attributes of a machine."""
431    try:
432        modify_dict = modifyDict(user, fields)
433    except InvalidInput, err:
434        result = None
435        machine = validation.testMachineId(user, fields.getfirst('machine_id'))
436    else:
437        machine = modify_dict['machine']
438        result = 'Success!'
439        err = None
440    info_dict = infoDict(user, machine)
441    info_dict['err'] = err
442    if err:
443        for field in fields.keys():
444            setattr(info_dict['defaults'], field, fields.getfirst(field))
445    info_dict['result'] = result
446    return templates.info(searchList=[info_dict])
447
448
449def helpHandler(user, fields):
450    """Handler for help messages."""
451    simple = fields.getfirst('simple')
452    subjects = fields.getlist('subject')
453
454    help_mapping = dict(paravm_console="""
455ParaVM machines do not support local console access over VNC.  To
456access the serial console of these machines, you can SSH with Kerberos
457to sipb-xen-console.mit.edu, using the name of the machine as your
458username.""",
459                        hvm_paravm="""
460HVM machines use the virtualization features of the processor, while
461ParaVM machines use Xen's emulation of virtualization features.  You
462want an HVM virtualized machine.""",
463                        cpu_weight="""
464Don't ask us!  We're as mystified as you are.""",
465                        owner="""
466The owner field is used to determine <a
467href="help?subject=quotas">quotas</a>.  It must be the name of a
468locker that you are an AFS administrator of.  In particular, you or an
469AFS group you are a member of must have AFS rlidwka bits on the
470locker.  You can check who administers the LOCKER locker using the
471commands 'attach LOCKER; fs la /mit/LOCKER' on Athena.)  See also <a
472href="help?subject=administrator">administrator</a>.""",
473                        administrator="""
474The administrator field determines who can access the console and
475power on and off the machine.  This can be either a user or a moira
476group.""",
477                        quotas="""
478Quotas are determined on a per-locker basis.  Each locker may have a
479maximum of 512 megabytes of active ram, 50 gigabytes of disk, and 4
480active machines.""",
481                        console="""
482<strong>Framebuffer:</strong> At a Linux boot prompt in your VM, try
483setting <tt>fb=false</tt> to disable the framebuffer.  If you don't,
484your machine will run just fine, but the applet's display of the
485console will suffer artifacts.
486"""
487                   )
488
489    if not subjects:
490        subjects = sorted(help_mapping.keys())
491
492    d = dict(user=user,
493             simple=simple,
494             subjects=subjects,
495             mapping=help_mapping)
496
497    return templates.help(searchList=[d])
498
499
500def badOperation(u, e):
501    """Function called when accessing an unknown URI."""
502    raise CodeError("Unknown operation")
503
504def infoDict(user, machine):
505    """Get the variables used by info.tmpl."""
506    status = controls.statusInfo(machine)
507    checkpoint.checkpoint('Getting status info')
508    has_vnc = hasVnc(status)
509    if status is None:
510        main_status = dict(name=machine.name,
511                           memory=str(machine.memory))
512        uptime = None
513        cputime = None
514    else:
515        main_status = dict(status[1:])
516        start_time = float(main_status.get('start_time', 0))
517        uptime = datetime.timedelta(seconds=int(time.time()-start_time))
518        cpu_time_float = float(main_status.get('cpu_time', 0))
519        cputime = datetime.timedelta(seconds=int(cpu_time_float))
520    checkpoint.checkpoint('Status')
521    display_fields = """name uptime memory state cpu_weight on_reboot
522     on_poweroff on_crash on_xend_start on_xend_stop bootloader""".split()
523    display_fields = [('name', 'Name'),
524                      ('owner', 'Owner'),
525                      ('administrator', 'Administrator'),
526                      ('contact', 'Contact'),
527                      ('type', 'Type'),
528                      'NIC_INFO',
529                      ('uptime', 'uptime'),
530                      ('cputime', 'CPU usage'),
531                      ('memory', 'RAM'),
532                      'DISK_INFO',
533                      ('state', 'state (xen format)'),
534                      ('cpu_weight', 'CPU weight'+helppopup('cpu_weight')),
535                      ('on_reboot', 'Action on VM reboot'),
536                      ('on_poweroff', 'Action on VM poweroff'),
537                      ('on_crash', 'Action on VM crash'),
538                      ('on_xend_start', 'Action on Xen start'),
539                      ('on_xend_stop', 'Action on Xen stop'),
540                      ('bootloader', 'Bootloader options'),
541                      ]
542    fields = []
543    machine_info = {}
544    machine_info['name'] = machine.name
545    machine_info['type'] = machine.type.hvm and 'HVM' or 'ParaVM'
546    machine_info['owner'] = machine.owner
547    machine_info['administrator'] = machine.administrator
548    machine_info['contact'] = machine.contact
549
550    nic_fields = getNicInfo(machine_info, machine)
551    nic_point = display_fields.index('NIC_INFO')
552    display_fields = (display_fields[:nic_point] + nic_fields +
553                      display_fields[nic_point+1:])
554
555    disk_fields = getDiskInfo(machine_info, machine)
556    disk_point = display_fields.index('DISK_INFO')
557    display_fields = (display_fields[:disk_point] + disk_fields +
558                      display_fields[disk_point+1:])
559
560    main_status['memory'] += ' MiB'
561    for field, disp in display_fields:
562        if field in ('uptime', 'cputime') and locals()[field] is not None:
563            fields.append((disp, locals()[field]))
564        elif field in machine_info:
565            fields.append((disp, machine_info[field]))
566        elif field in main_status:
567            fields.append((disp, main_status[field]))
568        else:
569            pass
570            #fields.append((disp, None))
571
572    checkpoint.checkpoint('Got fields')
573
574
575    max_mem = validation.maxMemory(user, machine, False)
576    checkpoint.checkpoint('Got mem')
577    max_disk = validation.maxDisk(user, machine)
578    defaults = Defaults()
579    for name in 'machine_id name administrator owner memory contact type'.split():
580        setattr(defaults, name, getattr(machine, name))
581    defaults.disk = "%0.2f" % (machine.disks[0].size/1024.)
582    checkpoint.checkpoint('Got defaults')
583    d = dict(user=user,
584             on=status is not None,
585             machine=machine,
586             defaults=defaults,
587             has_vnc=has_vnc,
588             uptime=str(uptime),
589             ram=machine.memory,
590             max_mem=max_mem,
591             max_disk=max_disk,
592             owner_help=helppopup("owner"),
593             fields = fields)
594    return d
595
596def info(user, fields):
597    """Handler for info on a single VM."""
598    machine = validation.testMachineId(user, fields.getfirst('machine_id'))
599    d = infoDict(user, machine)
600    checkpoint.checkpoint('Got infodict')
601    return templates.info(searchList=[d])
602
603mapping = dict(list=listVms,
604               vnc=vnc,
605               command=command,
606               modify=modify,
607               info=info,
608               create=create,
609               help=helpHandler)
610
611def printHeaders(headers):
612    """Print a dictionary as HTTP headers."""
613    for key, value in headers.iteritems():
614        print '%s: %s' % (key, value)
615    print
616
617
618def getUser():
619    """Return the current user based on the SSL environment variables"""
620    username = os.environ['SSL_CLIENT_S_DN_Email'].split("@")[0]
621    return username
622
623def main(operation, user, fields):
624    start_time = time.time()
625    fun = mapping.get(operation, badOperation)
626
627    if fun not in (helpHandler, ):
628        connect('postgres://sipb-xen@sipb-xen-dev.mit.edu/sipb_xen')
629    try:
630        checkpoint.checkpoint('Before')
631        output = fun(u, fields)
632        checkpoint.checkpoint('After')
633
634        headers = dict(DEFAULT_HEADERS)
635        if isinstance(output, tuple):
636            new_headers, output = output
637            headers.update(new_headers)
638        e = revertStandardError()
639        if e:
640            output.addError(e)
641        printHeaders(headers)
642        output_string =  str(output)
643        checkpoint.checkpoint('output as a string')
644        print output_string
645        print '<!-- <pre>%s</pre> -->' % checkpoint
646    except Exception, err:
647        if not fields.has_key('js'):
648            if isinstance(err, CodeError):
649                print 'Content-Type: text/html\n'
650                e = revertStandardError()
651                print error(operation, u, fields, err, e)
652                sys.exit(1)
653            if isinstance(err, InvalidInput):
654                print 'Content-Type: text/html\n'
655                e = revertStandardError()
656                print invalidInput(operation, u, fields, err, e)
657                sys.exit(1)
658        print 'Content-Type: text/plain\n'
659        print 'Uh-oh!  We experienced an error.'
660        print 'Please email sipb-xen@mit.edu with the contents of this page.'
661        print '----'
662        e = revertStandardError()
663        print e
664        print '----'
665        raise
666
667if __name__ == '__main__':
668    fields = cgi.FieldStorage()
669    u = getUser()
670    g.user = u
671    operation = os.environ.get('PATH_INFO', '')
672    if not operation:
673        print "Status: 301 Moved Permanently"
674        print 'Location: ' + os.environ['SCRIPT_NAME']+'/\n'
675        sys.exit(0)
676
677    if operation.startswith('/'):
678        operation = operation[1:]
679    if not operation:
680        operation = 'list'
681
682    if os.getenv("SIPB_XEN_PROFILE"):
683        import profile
684        profile.run('main(operation, u, fields)', 'log-'+operation)
685    else:
686        main(operation, u, fields)
Note: See TracBrowser for help on using the repository browser.