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