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

Last change on this file since 2882 was 2815, checked in by broder, 15 years ago

Fix modifying powered-on machines.

  • Property svn:executable set to *
File size: 25.8 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        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': """
140The autoinstaller builds a minimal Debian or Ubuntu system to run as a
141ParaVM.  You can access the resulting system by logging into the <a
142href="help?simple=true&subject=ParaVM+Console">serial console server</a>
143with your Kerberos tickets; there is no root password so sshd will
144refuse login.</p>
145
146<p>Under the covers, the autoinstaller uses our own patched version of
147xen-create-image, which is a tool based on debootstrap.  If you log
148into the serial console while the install is running, you can watch
149it.
150""",
151            'ParaVM Console': """
152ParaVM machines do not support local console access over VNC.  To
153access the serial console of these machines, you can SSH with Kerberos
154to %s, using the name of the machine as your
155username.""" % config.console.hostname,
156            'HVM/ParaVM': """
157HVM machines use the virtualization features of the processor, while
158ParaVM machines rely on a modified kernel to communicate directly with
159the hypervisor.  HVMs support boot CDs of any operating system, and
160the VNC console applet.  The three-minute autoinstaller produces
161ParaVMs.  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
165href="https://xvm.scripts.mit.edu/wiki/Paravirtualization">on the
166wiki</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': """
172Don't ask us!  We're as mystified as you are.""",
173            'Owner': """
174The owner field is used to determine <a
175href="help?subject=Quotas">quotas</a>.  It must be the name of a
176locker that you are an AFS administrator of.  In particular, you or an
177AFS group you are a member of must have AFS rlidwka bits on the
178locker.  You can check who administers the LOCKER locker using the
179commands 'attach LOCKER; fs la /mit/LOCKER' on Athena.)  See also <a
180href="help?subject=Administrator">administrator</a>.""",
181            'Administrator': """
182The administrator field determines who can access the console and
183power on and off the machine.  This can be either a user or a moira
184group.""",
185            'Quotas': """
186Quotas are determined on a per-locker basis.  Each locker may have a
187maximum of 512 mebibytes of active ram, 50 gibibytes of disk, and 4
188active machines.""",
189            'Console': """
190<strong>Framebuffer:</strong> At a Linux boot prompt in your VM, try
191setting <tt>fb=false</tt> to disable the framebuffer.  If you don't,
192your machine will run just fine, but the applet's display of the
193console 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
394class 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
413def 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
424def 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
462def 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
478def 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
499def 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
515def 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
575def 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
656def send_error_mail(subject, body):
657    import subprocess
658
659    to = config.web.errormail
660    mail = """To: %s
661From: root@%s
662Subject: %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
672random.seed() #sigh
Note: See TracBrowser for help on using the repository browser.