How to marshal a function?

François Pinard pinard at iro.umontreal.ca
Wed Nov 21 00:12:46 CET 2001


[Cliff Wells]

> As far as whether this will provide sufficient security, that's obviously 
> more difficult to say.  It was merely my original intention to point out 
> the possibility of a security hole (there was no information regarding how 
> he was using this application in the first few posts), it seeming likely 
> to me that more information regarding his particular application would be 
> forthcoming, but the discussion didn't continue in this vein, so this was 
> never addressed.

OK.  I just translated the doc-strings and comments to English, so I can
share the little tool with you.  That should allow you people to evaluate
this module's security, or lack thereof, and advise me!

Besides, I think the tool may be useful in itself, to some of you.  For one,
I use this module within a bigger setup meant for administrating systems
and user accounts for many machines at once, and in parallel.

-------------- next part --------------
#!/usr/bin/env python
# Copyright ? 2001 Progiciels Bourbeau-Pinard inc.
# Fran?ois Pinard <pinard at iro.umontreal.ca>, 2001.

"""\
Python services on a remote machine.

To each Server instance is associated an `ssh' link towards a remote server
program.  That remote server, which gets automatically installed, is able to
evaluate Python expressions, apply functions or execute Python statements,
on demand, within in a special evaluation context held within that server.
The `pickle' module is used for all transit to or from the server, so the
programmer should restrain him/herself to Python values that can be pickled.

Here is a simplistic example.  Suppose `cliff' is an Internet host for
which we already have immediate SSH access through the proper key setup.
To get `cliff' to compute `2 + 3', a Python expression, one uses this:

    import remote
    server = remote.Server('cliff')
    print server.eval('2 + 3')
    server.complete()

If the host name is missing or None, the current host is directly used,
without installing nor using a remote server.

The server is installed as `~/.python-remote-VERSION' on the remote host
(VERSION identifies the protocol) and left there afterwards.  If the server
already exists, it is merely reused if it identifies itself correctly.

Typically, the link is kept opened to service many requests which depend
on the remote machine, either for its computing power, its file system,
or other idiosyncrasies, and closed once the overall task is completed.
"""

import string, sys

error = 'Remote error'

APPLY_CODE, EVAL_CODE, EXECUTE_CODE = range(3)
NORMAL_RETURN, ERROR_RETURN = range(2)

class run:
    version = 1
    header = "Python `remote' server, protocol version %d" % version
    script = '.python-remote-%d' % version

def main(*arguments):
    import getopt
    options, arguments = getopt.getopt(arguments, '')
    for option, value in options:
        pass
    execute_server()

class Server:

    def __init__(self, host=None):
        if host is None:
            self.child = None
            self.context = {}
            return
        import os, popen2
        self.host = host
        name = __file__
        if name[-4:] == '.pyc':
            name = name[:-4] + '.py'
        command = 'ssh -x %s python %s' % (host, run.script)
        for counter in range(2):
            self.child = popen2.Popen3(command)
            text = self.receive_text()
            if text == '%s\n' % run.header:
                return
            if text is None:
                sys.stderr.write("Oops!  Installing Python server on `%s'\n"
                                 % host)
            os.system('scp -pq %s %s:%s' % (name, host, run.script))
        assert 0, "Unable to install `%s' on `%s'." % (name, host)

    def complete(self):
        if self.child is not None:
            self.send_text('')
            text = self.receive_text()
            assert text == '', text
            self.child = None

    def apply(self, text, arguments):
        """\
Evaluate TEXT, which should yield a function on the remote server.
Then apply this function over ARGUMENTS, and return the function value.
"""
        if self.child is None:
            return apply(eval(text, globals(), self.context), arguments)
        return self.round_trip((APPLY_CODE, (text, arguments)))

    def eval(self, text):
        """\
Get the remote server to evaluate TEXT as an expression, and return its value.
"""
        if self.child is None:
            return eval(text, globals(), self.context)
        return self.round_trip((EVAL_CODE, text))

    def execute(self, text):
        """\
Execute TEXT as Python statements on the remote server.  Return None.
"""
        if self.child is None:
            exec text in globals(), self.context
            return
        return self.round_trip((EXECUTE_CODE, text))

    def round_trip(self, request):
        import base64, pickle, zlib
        text = base64.encodestring(zlib.compress(pickle.dumps(request)))
        self.send_text(text)
        text = self.receive_text()
        if text is None:
            return None
        code, value = pickle.loads(zlib.decompress(base64.decodestring(text)))
        if code == ERROR_RETURN:
            raise error, value
        return value

    def send_text(self, text):
        assert self.child.poll() == -1, \
               "%s: Python server has been interrupted." % self.host
        self.child.tochild.write(text + '\n')
        self.child.tochild.flush()

    def receive_text(self):
        assert self.child.poll() == -1, \
               "%s: Python server has been interrupted." % self.host
        lines = []
        while 1:
            line = self.child.fromchild.readline()
            if not line:
                break
            if line == '\n':
                return string.join(lines, '')
            lines.append(line)

def execute_server():
    """\
Python remote server proper.

Here is a description of the communication protocol.  The server identifies
itself on a single stdout line, followed by an empty line.  It then enters a
loop reading one request on stdin terminated by an empty line, and writing
the reply on stdout, followed by an empty line.  Requests and replies are
compressed pickles which are Base64-coded over possibly multiple lines.
All requests are processed within a same single context for local variables.
An empty request produces an empty reply and the termination of this server.
"""
    import StringIO, base64, pickle, traceback, zlib
    context = {}
    readline = sys.stdin.readline
    write = sys.stdout.write
    flush = sys.stdout.flush
    write('%s\n\n' % run.header)
    flush()
    lines = []
    while 1:
        line = readline()
        if line != '\n':
            lines.append(line)
            continue
        text = string.join(lines, '')
        if text == '':
            write('\n')
            break
        lines = []
        request = pickle.loads(zlib.decompress(base64.decodestring(text)))
        code, text = request
        try:
            if code == APPLY_CODE:
                text, arguments = text
                code = NORMAL_RETURN
                value = apply(eval(text, globals(), context), arguments)
            elif code == EVAL_CODE:
                code = NORMAL_RETURN
                value = eval(text, globals(), context)
            else:
                exec text in globals(), context
                code = NORMAL_RETURN
                value = None
        except:
            message = StringIO.StringIO()
            traceback.print_exc(file=message)
            code = ERROR_RETURN
            value = message.getvalue()
        write(base64.encodestring(zlib.compress(pickle.dumps((code, value)))))
        write('\n')
        flush()

if __name__ == '__main__':
    apply(main, sys.argv[1:])
-------------- next part --------------

-- 
Fran?ois Pinard   http://www.iro.umontreal.ca/~pinard


More information about the Python-list mailing list