changed <table> to <div>
[invirt/packages/invirt-web.git] / code / main.py
1 #!/usr/bin/python
2 """Main CGI script for web interface"""
3
4 import base64
5 import cPickle
6 import cgi
7 import datetime
8 import hmac
9 import os
10 import random
11 import sha
12 import sys
13 import time
14 import urllib
15 import socket
16 import cherrypy
17 from cherrypy import _cperror
18 from StringIO import StringIO
19
20 def printError():
21     """Revert stderr to stdout, and print the contents of stderr"""
22     if isinstance(sys.stderr, StringIO):
23         print revertStandardError()
24
25 if __name__ == '__main__':
26     import atexit
27     atexit.register(printError)
28
29 import validation
30 import cache_acls
31 from webcommon import State
32 import controls
33 from getafsgroups import getAfsGroupMembers
34 from invirt import database
35 from invirt.database import Machine, CDROM, session, connect, MachineAccess, Type, Autoinstall
36 from invirt.config import structs as config
37 from invirt.common import InvalidInput, CodeError
38
39 from view import View, revertStandardError
40
41
42 static_dir = os.path.join(os.path.dirname(__file__), 'static')
43 InvirtStatic = cherrypy.tools.staticdir.handler(
44     root=static_dir,
45     dir=static_dir,
46     section='/static')
47
48 class InvirtUnauthWeb(View):
49     static = InvirtStatic
50
51     @cherrypy.expose
52     @cherrypy.tools.mako(filename="/unauth.mako")
53     def index(self):
54         d = dict(simple=True)
55         if os.path.exists("/etc/invirt/message"):
56             f = open('/etc/invirt/message')
57             d['serviceMessage']= f.read()
58         return d
59
60 class InvirtWeb(View):
61     def __init__(self):
62         super(self.__class__,self).__init__()
63         connect()
64         self._cp_config['tools.require_login.on'] = True
65         self._cp_config['tools.catch_stderr.on'] = True
66         self._cp_config['tools.mako.imports'] = ['from invirt.config import structs as config',
67                                                  'from invirt import database']
68         self._cp_config['request.error_response'] = self.handle_error
69
70     static = InvirtStatic
71
72     @cherrypy.expose
73     @cherrypy.tools.mako(filename="/invalid.mako")
74     def invalidInput(self):
75         """Print an error page when an InvalidInput exception occurs"""
76         err = cherrypy.request.prev.params["err"]
77         emsg = cherrypy.request.prev.params["emsg"]
78         d = dict(err_field=err.err_field,
79                  err_value=str(err.err_value), stderr=emsg,
80                  errorMessage=str(err))
81         return d
82
83     @cherrypy.expose
84     @cherrypy.tools.mako(filename="/error.mako")
85     def error(self):
86         """Print an error page when an exception occurs"""
87         op = cherrypy.request.prev.path_info
88         username = cherrypy.request.login
89         err = cherrypy.request.prev.params["err"]
90         emsg = cherrypy.request.prev.params["emsg"]
91         traceback = cherrypy.request.prev.params["traceback"]
92         d = dict(op=op, user=username, fields=cherrypy.request.prev.params,
93                  errorMessage=str(err), stderr=emsg, traceback=traceback)
94         error_raw = cherrypy.request.lookup.get_template("/error_raw.mako")
95         details = error_raw.render(**d)
96         exclude = config.web.errormail_exclude
97         if username not in exclude and '*' not in exclude:
98             send_error_mail('xvm error on %s for %s: %s' % (op, cherrypy.request.login, err),
99                             details)
100         d['details'] = details
101         return d
102
103     def __getattr__(self, name):
104         if name in ("admin", "overlord"):
105             if not cherrypy.request.login in getAfsGroupMembers(config.adminacl, config.authz.afs.cells[0].cell):
106                 raise InvalidInput('username', cherrypy.request.login,
107                                    'Not in admin group %s.' % config.adminacl)
108             cherrypy.request.state = State(cherrypy.request.login, isadmin=True)
109             return self
110         else:
111             return super(InvirtWeb, self).__getattr__(name)
112
113     def handle_error(self):
114         err = sys.exc_info()[1]
115         if isinstance(err, InvalidInput):
116             cherrypy.request.params['err'] = err
117             cherrypy.request.params['emsg'] = revertStandardError()
118             raise cherrypy.InternalRedirect('/invalidInput')
119         if not cherrypy.request.prev or 'err' not in cherrypy.request.prev.params:
120             cherrypy.request.params['err'] = err
121             cherrypy.request.params['emsg'] = revertStandardError()
122             cherrypy.request.params['traceback'] = _cperror.format_exc()
123             raise cherrypy.InternalRedirect('/error')
124         # fall back to cherrypy default error page
125         cherrypy.HTTPError(500).set_response()
126
127     @cherrypy.expose
128     @cherrypy.tools.mako(filename="/list.mako")
129     def list(self, result=None):
130         """Handler for list requests."""
131         d = getListDict(cherrypy.request.login, cherrypy.request.state)
132         if result is not None:
133             d['result'] = result
134         return d
135     index=list
136
137     @cherrypy.expose
138     @cherrypy.tools.mako(filename="/help.mako")
139     def help(self, subject=None, simple=False):
140         """Handler for help messages."""
141
142         help_mapping = {
143             'Autoinstalls': """
144 The autoinstaller builds a minimal Debian or Ubuntu system to run as a
145 ParaVM.  You can access the resulting system by logging into the <a
146 href="help?simple=true&subject=ParaVM+Console">serial console server</a>
147 with your Kerberos tickets; there is no root password so sshd will
148 refuse login.</p>
149
150 <p>Under the covers, the autoinstaller uses our own patched version of
151 xen-create-image, which is a tool based on debootstrap.  If you log
152 into the serial console while the install is running, you can watch
153 it.
154 """,
155             'ParaVM Console': """
156 ParaVM machines do not support local console access over VNC.  To
157 access the serial console of these machines, you can SSH with Kerberos
158 to %s, using the name of the machine as your
159 username.""" % config.console.hostname,
160             'HVM/ParaVM': """
161 HVM machines use the virtualization features of the processor, while
162 ParaVM machines rely on a modified kernel to communicate directly with
163 the hypervisor.  HVMs support boot CDs of any operating system, and
164 the VNC console applet.  The three-minute autoinstaller produces
165 ParaVMs.  ParaVMs typically are more efficient, and always support the
166 <a href="help?subject=ParaVM+Console">console server</a>.</p>
167
168 <p>More details are <a
169 href="https://xvm.scripts.mit.edu/wiki/Paravirtualization">on the
170 wiki</a>, including steps to prepare an HVM guest to boot as a ParaVM
171 (which you can skip by using the autoinstaller to begin with.)</p>
172
173 <p>We recommend using a ParaVM when possible and an HVM when necessary.
174 """,
175             'CPU Weight': """
176 Don't ask us!  We're as mystified as you are.""",
177             'Owner': """
178 The owner field is used to determine <a
179 href="help?subject=Quotas">quotas</a>.  It must be the name of a
180 locker that you are an AFS administrator of.  In particular, you or an
181 AFS group you are a member of must have AFS rlidwka bits on the
182 locker.  You can check who administers the LOCKER locker using the
183 commands 'attach LOCKER; fs la /mit/LOCKER' on Athena.)  See also <a
184 href="help?subject=Administrator">administrator</a>.""",
185             'Administrator': """
186 The administrator field determines who can access the console and
187 power on and off the machine.  This can be either a user or a moira
188 group.""",
189             'Quotas': """
190 Quotas are determined on a per-locker basis.  Each locker may have a
191 maximum of 512 mebibytes of active ram, 50 gibibytes of disk, and 4
192 active machines.""",
193             'Console': """
194 <strong>Framebuffer:</strong> At a Linux boot prompt in your VM, try
195 setting <tt>fb=false</tt> to disable the framebuffer.  If you don't,
196 your machine will run just fine, but the applet's display of the
197 console will suffer artifacts.
198 """,
199             'Windows': """
200 <strong>Windows Vista:</strong> The Vista image is licensed for all MIT students and will automatically activate off the network; see <a href="/static/msca-email.txt">the licensing confirmation e-mail</a> for details. The installer requires 512 MiB RAM and at least 7.5 GiB disk space (15 GiB or more recommended).<br>
201 <strong>Windows XP:</strong> 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 <a href="http://msca.mit.edu/">http://msca.mit.edu/</a> if you are staff/faculty to request one.
202 """
203             }
204
205         if not subject:
206             subject = sorted(help_mapping.keys())
207         if not isinstance(subject, list):
208             subject = [subject]
209
210         return dict(simple=simple,
211                     subjects=subject,
212                     mapping=help_mapping)
213     help._cp_config['tools.require_login.on'] = False
214
215     def parseCreate(self, fields):
216         kws = dict([(kw, fields[kw]) for kw in
217          'name description owner memory disksize vmtype cdrom autoinstall'.split()
218                     if fields[kw]])
219         validate = validation.Validate(cherrypy.request.login,
220                                        cherrypy.request.state,
221                                        strict=True, **kws)
222         return dict(contact=cherrypy.request.login, name=validate.name,
223                     description=validate.description, memory=validate.memory,
224                     disksize=validate.disksize, owner=validate.owner,
225                     machine_type=getattr(validate, 'vmtype', Defaults.type),
226                     cdrom=getattr(validate, 'cdrom', None),
227                     autoinstall=getattr(validate, 'autoinstall', None))
228
229     @cherrypy.expose
230     @cherrypy.tools.mako(filename="/list.mako")
231     @cherrypy.tools.require_POST()
232     def create(self, **fields):
233         """Handler for create requests."""
234         try:
235             parsed_fields = self.parseCreate(fields)
236             machine = controls.createVm(cherrypy.request.login,
237                                         cherrypy.request.state, **parsed_fields)
238         except InvalidInput, err:
239             pass
240         else:
241             err = None
242         cherrypy.request.state.clear() #Changed global state
243         d = getListDict(cherrypy.request.login, cherrypy.request.state)
244         d['err'] = err
245         if err:
246             for field, value in fields.items():
247                 setattr(d['defaults'], field, value)
248         else:
249             d['new_machine'] = parsed_fields['name']
250         return d
251
252     @cherrypy.expose
253     @cherrypy.tools.mako(filename="/helloworld.mako")
254     def helloworld(self, **kwargs):
255         return {'request': cherrypy.request, 'kwargs': kwargs}
256     helloworld._cp_config['tools.require_login.on'] = False
257
258     @cherrypy.expose
259     def errortest(self):
260         """Throw an error, to test the error-tracing mechanisms."""
261         print >>sys.stderr, "look ma, it's a stderr"
262         raise RuntimeError("test of the emergency broadcast system")
263
264     class MachineView(View):
265         def __getattr__(self, name):
266             """Synthesize attributes to allow RESTful URLs like
267             /machine/13/info. This is hairy. CherryPy 3.2 adds a
268             method called _cp_dispatch that allows you to explicitly
269             handle URLs that can't be mapped, and it allows you to
270             rewrite the path components and continue processing.
271
272             This function gets the next path component being resolved
273             as a string. _cp_dispatch will get an array of strings
274             representing any subsequent path components as well."""
275
276             try:
277                 cherrypy.request.params['machine_id'] = int(name)
278                 return self
279             except ValueError:
280                 return None
281
282         @cherrypy.expose
283         @cherrypy.tools.mako(filename="/info.mako")
284         def info(self, machine_id):
285             """Handler for info on a single VM."""
286             machine = validation.Validate(cherrypy.request.login,
287                                           cherrypy.request.state,
288                                           machine_id=machine_id).machine
289             d = infoDict(cherrypy.request.login, cherrypy.request.state, machine)
290             return d
291         index = info
292
293         @cherrypy.expose
294         @cherrypy.tools.mako(filename="/info.mako")
295         @cherrypy.tools.require_POST()
296         def modify(self, machine_id, **fields):
297             """Handler for modifying attributes of a machine."""
298             try:
299                 modify_dict = modifyDict(cherrypy.request.login,
300                                          cherrypy.request.state,
301                                          machine_id, fields)
302             except InvalidInput, err:
303                 result = None
304                 machine = validation.Validate(cherrypy.request.login,
305                                               cherrypy.request.state,
306                                               machine_id=machine_id).machine
307             else:
308                 machine = modify_dict['machine']
309                 result = 'Success!'
310                 err = None
311             info_dict = infoDict(cherrypy.request.login,
312                                  cherrypy.request.state, machine)
313             info_dict['err'] = err
314             if err:
315                 for field, value in fields.items():
316                     setattr(info_dict['defaults'], field, value)
317             info_dict['result'] = result
318             return info_dict
319
320         @cherrypy.expose
321         @cherrypy.tools.mako(filename="/vnc.mako")
322         def vnc(self, machine_id):
323             """VNC applet page.
324
325             Note that due to same-domain restrictions, the applet connects to
326             the webserver, which needs to forward those requests to the xen
327             server.  The Xen server runs another proxy that (1) authenticates
328             and (2) finds the correct port for the VM.
329
330             You might want iptables like:
331
332             -t nat -A PREROUTING -s ! 18.181.0.60 -i eth1 -p tcp -m tcp \
333             --dport 10003 -j DNAT --to-destination 18.181.0.60:10003
334             -t nat -A POSTROUTING -d 18.181.0.60 -o eth1 -p tcp -m tcp \
335             --dport 10003 -j SNAT --to-source 18.187.7.142
336             -A FORWARD -d 18.181.0.60 -i eth1 -o eth1 -p tcp -m tcp \
337             --dport 10003 -j ACCEPT
338
339             Remember to enable iptables!
340             echo 1 > /proc/sys/net/ipv4/ip_forward
341             """
342             machine = validation.Validate(cherrypy.request.login,
343                                           cherrypy.request.state,
344                                           machine_id=machine_id).machine
345             token = controls.vnctoken(machine)
346             host = controls.listHost(machine)
347             if host:
348                 port = 10003 + [h.hostname for h in config.hosts].index(host)
349             else:
350                 port = 5900 # dummy
351
352             status = controls.statusInfo(machine)
353             has_vnc = hasVnc(status)
354
355             d = dict(on=status,
356                      has_vnc=has_vnc,
357                      machine=machine,
358                      hostname=cherrypy.request.local.name,
359                      port=port,
360                      authtoken=token)
361             return d
362
363         @cherrypy.expose
364         @cherrypy.tools.mako(filename="/command.mako")
365         @cherrypy.tools.require_POST()
366         def command(self, command_name, machine_id, **kwargs):
367             """Handler for running commands like boot and delete on a VM."""
368             back = kwargs.get('back')
369             if command_name == 'delete':
370                 back = 'list'
371             try:
372                 d = controls.commandResult(cherrypy.request.login,
373                                            cherrypy.request.state,
374                                            command_name, machine_id, kwargs)
375             except InvalidInput, err:
376                 if not back:
377                     raise
378                 print >> sys.stderr, err
379                 result = str(err)
380             else:
381                 result = 'Success!'
382                 if not back:
383                     return d
384             if back == 'list':
385                 cherrypy.request.state.clear() #Changed global state
386                 raise cherrypy.InternalRedirect('/list?result=%s'
387                                                 % urllib.quote(result))
388             elif back == 'info':
389                 raise cherrypy.HTTPRedirect(cherrypy.request.base
390                                             + '/machine/%d/' % machine_id,
391                                             status=303)
392             else:
393                 raise InvalidInput('back', back, 'Not a known back page.')
394
395     machine = MachineView()
396
397
398 class Defaults:
399     """Class to store default values for fields."""
400     memory = 256
401     disk = 4.0
402     cdrom = ''
403     autoinstall = ''
404     name = ''
405     description = ''
406     administrator = ''
407     type = 'linux-hvm'
408
409     def __init__(self, max_memory=None, max_disk=None, **kws):
410         if max_memory is not None:
411             self.memory = min(self.memory, max_memory)
412         if max_disk is not None:
413             self.disk = min(self.disk, max_disk)
414         for key in kws:
415             setattr(self, key, kws[key])
416
417 def hasVnc(status):
418     """Does the machine with a given status list support VNC?"""
419     if status is None:
420         return False
421     for l in status:
422         if l[0] == 'device' and l[1][0] == 'vfb':
423             d = dict(l[1][1:])
424             return 'location' in d
425     return False
426
427
428 def getListDict(username, state):
429     """Gets the list of local variables used by list.tmpl."""
430     machines = state.machines
431     on = {}
432     has_vnc = {}
433     installing = {}
434     xmlist = state.xmlist
435     for m in machines:
436         if m not in xmlist:
437             has_vnc[m] = 'Off'
438             m.uptime = None
439         else:
440             m.uptime = xmlist[m]['uptime']
441             installing[m] = bool(xmlist[m].get('autoinstall'))
442             if xmlist[m]['console']:
443                 has_vnc[m] = True
444             elif m.type.hvm:
445                 has_vnc[m] = "WTF?"
446             else:
447                 has_vnc[m] = "ParaVM"
448     max_memory = validation.maxMemory(username, state)
449     max_disk = validation.maxDisk(username)
450     defaults = Defaults(max_memory=max_memory,
451                         max_disk=max_disk,
452                         owner=username)
453     def sortkey(machine):
454         return (machine.owner != username, machine.owner, machine.name)
455     machines = sorted(machines, key=sortkey)
456     d = dict(user=username,
457              cant_add_vm=validation.cantAddVm(username, state),
458              max_memory=max_memory,
459              max_disk=max_disk,
460              defaults=defaults,
461              machines=machines,
462              has_vnc=has_vnc,
463              installing=installing)
464     return d
465
466 def getHostname(nic):
467     """Find the hostname associated with a NIC.
468
469     XXX this should be merged with the similar logic in DNS and DHCP.
470     """
471     if nic.hostname:
472         hostname = nic.hostname
473     elif nic.machine:
474         hostname = nic.machine.name
475     else:
476         return None
477     if '.' in hostname:
478         return hostname
479     else:
480         return hostname + '.' + config.dns.domains[0]
481
482 def getNicInfo(data_dict, machine):
483     """Helper function for info, get data on nics for a machine.
484
485     Modifies data_dict to include the relevant data, and returns a list
486     of (key, name) pairs to display "name: data_dict[key]" to the user.
487     """
488     data_dict['num_nics'] = len(machine.nics)
489     nic_fields_template = [('nic%s_hostname', 'NIC %s Hostname'),
490                            ('nic%s_mac', 'NIC %s MAC Addr'),
491                            ('nic%s_ip', 'NIC %s IP'),
492                            ]
493     nic_fields = []
494     for i in range(len(machine.nics)):
495         nic_fields.extend([(x % i, y % i) for x, y in nic_fields_template])
496         data_dict['nic%s_hostname' % i] = getHostname(machine.nics[i])
497         data_dict['nic%s_mac' % i] = machine.nics[i].mac_addr
498         data_dict['nic%s_ip' % i] = machine.nics[i].ip
499     if len(machine.nics) == 1:
500         nic_fields = [(x, y.replace('NIC 0 ', '')) for x, y in nic_fields]
501     return nic_fields
502
503 def getDiskInfo(data_dict, machine):
504     """Helper function for info, get data on disks for a machine.
505
506     Modifies data_dict to include the relevant data, and returns a list
507     of (key, name) pairs to display "name: data_dict[key]" to the user.
508     """
509     data_dict['num_disks'] = len(machine.disks)
510     disk_fields_template = [('%s_size', '%s size')]
511     disk_fields = []
512     for disk in machine.disks:
513         name = disk.guest_device_name
514         disk_fields.extend([(x % name, y % name) for x, y in
515                             disk_fields_template])
516         data_dict['%s_size' % name] = "%0.1f GiB" % (disk.size / 1024.)
517     return disk_fields
518
519 def modifyDict(username, state, machine_id, fields):
520     """Modify a machine as specified by CGI arguments.
521
522     Return a dict containing the machine that was modified.
523     """
524     olddisk = {}
525     session.begin()
526     try:
527         kws = dict([(kw, fields[kw]) for kw in
528          'owner admin contact name description memory vmtype disksize'.split()
529                     if fields[kw]])
530         kws['machine_id'] = machine_id
531         validate = validation.Validate(username, state, **kws)
532         machine = validate.machine
533         oldname = machine.name
534
535         if hasattr(validate, 'memory'):
536             machine.memory = validate.memory
537
538         if hasattr(validate, 'vmtype'):
539             machine.type = validate.vmtype
540
541         if hasattr(validate, 'disksize'):
542             disksize = validate.disksize
543             disk = machine.disks[0]
544             if disk.size != disksize:
545                 olddisk[disk.guest_device_name] = disksize
546                 disk.size = disksize
547                 session.save_or_update(disk)
548
549         update_acl = False
550         if hasattr(validate, 'owner') and validate.owner != machine.owner:
551             machine.owner = validate.owner
552             update_acl = True
553         if hasattr(validate, 'name'):
554             machine.name = validate.name
555             for n in machine.nics:
556                 if n.hostname == oldname:
557                     n.hostname = validate.name
558         if hasattr(validate, 'description'):
559             machine.description = validate.description
560         if hasattr(validate, 'admin') and validate.admin != machine.administrator:
561             machine.administrator = validate.admin
562             update_acl = True
563         if hasattr(validate, 'contact'):
564             machine.contact = validate.contact
565
566         session.save_or_update(machine)
567         if update_acl:
568             cache_acls.refreshMachine(machine)
569         session.commit()
570     except:
571         session.rollback()
572         raise
573     for diskname in olddisk:
574         controls.resizeDisk(oldname, diskname, str(olddisk[diskname]))
575     if hasattr(validate, 'name'):
576         controls.renameMachine(machine, oldname, validate.name)
577     return dict(machine=machine)
578
579 def infoDict(username, state, machine):
580     """Get the variables used by info.tmpl."""
581     status = controls.statusInfo(machine)
582     has_vnc = hasVnc(status)
583     if status is None:
584         main_status = dict(name=machine.name,
585                            memory=str(machine.memory))
586         uptime = None
587         cputime = None
588     else:
589         main_status = dict(status[1:])
590         main_status['host'] = controls.listHost(machine)
591         start_time = float(main_status.get('start_time', 0))
592         uptime = datetime.timedelta(seconds=int(time.time()-start_time))
593         cpu_time_float = float(main_status.get('cpu_time', 0))
594         cputime = datetime.timedelta(seconds=int(cpu_time_float))
595     display_fields = [('name', 'Name'),
596                       ('description', 'Description'),
597                       ('owner', 'Owner'),
598                       ('administrator', 'Administrator'),
599                       ('contact', 'Contact'),
600                       ('type', 'Type'),
601                       'NIC_INFO',
602                       ('uptime', 'uptime'),
603                       ('cputime', 'CPU usage'),
604                       ('host', 'Hosted on'),
605                       ('memory', 'RAM'),
606                       'DISK_INFO',
607                       ('state', 'state (xen format)'),
608                       ]
609     fields = []
610     machine_info = {}
611     machine_info['name'] = machine.name
612     machine_info['description'] = machine.description
613     machine_info['type'] = machine.type.hvm and 'HVM' or 'ParaVM'
614     machine_info['owner'] = machine.owner
615     machine_info['administrator'] = machine.administrator
616     machine_info['contact'] = machine.contact
617
618     nic_fields = getNicInfo(machine_info, machine)
619     nic_point = display_fields.index('NIC_INFO')
620     display_fields = (display_fields[:nic_point] + nic_fields +
621                       display_fields[nic_point+1:])
622
623     disk_fields = getDiskInfo(machine_info, machine)
624     disk_point = display_fields.index('DISK_INFO')
625     display_fields = (display_fields[:disk_point] + disk_fields +
626                       display_fields[disk_point+1:])
627
628     main_status['memory'] += ' MiB'
629     for field, disp in display_fields:
630         if field in ('uptime', 'cputime') and locals()[field] is not None:
631             fields.append((disp, locals()[field]))
632         elif field in machine_info:
633             fields.append((disp, machine_info[field]))
634         elif field in main_status:
635             fields.append((disp, main_status[field]))
636         else:
637             pass
638             #fields.append((disp, None))
639
640     max_mem = validation.maxMemory(machine.owner, state, machine, False)
641     max_disk = validation.maxDisk(machine.owner, machine)
642     defaults = Defaults()
643     for name in 'machine_id name description administrator owner memory contact'.split():
644         if getattr(machine, name):
645             setattr(defaults, name, getattr(machine, name))
646     defaults.type = machine.type.type_id
647     defaults.disk = "%0.2f" % (machine.disks[0].size/1024.)
648     d = dict(user=username,
649              on=status is not None,
650              machine=machine,
651              defaults=defaults,
652              has_vnc=has_vnc,
653              uptime=str(uptime),
654              ram=machine.memory,
655              max_mem=max_mem,
656              max_disk=max_disk,
657              fields = fields)
658     return d
659
660 def send_error_mail(subject, body):
661     import subprocess
662
663     to = config.web.errormail
664     mail = """To: %s
665 From: root@%s
666 Subject: %s
667
668 %s
669 """ % (to, config.web.hostname, subject, body)
670     p = subprocess.Popen(['/usr/sbin/sendmail', '-f', to, to],
671                          stdin=subprocess.PIPE)
672     p.stdin.write(mail)
673     p.stdin.close()
674     p.wait()
675
676 random.seed() #sigh