source: trunk/packages/invirt-web/code/main.py @ 3016

Last change on this file since 3016 was 2979, checked in by quentin, 15 years ago

Work around quirk of CherryPy? dispatching that causes username munging to not happen by the time dispatching happens

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