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