From b045f220692e016a105f03af025d49f9a9cddc74 Mon Sep 17 00:00:00 2001 From: Rob Crittenden Date: Thu, 16 Oct 2008 23:33:44 -0400 Subject: Add mod_python-based XML-RPC server. Use -e kerberos on the command-line to use the mod_python server, otherwise it defaults to use the simple-server URL. --- ipa_xmlrpc/ipaxmlrpc.py | 399 +++++++++++++++++++++++++++++++++++++++++++++ ipalib/errors.py | 2 + ipalib/plugins/b_xmlrpc.py | 47 +++++- 3 files changed, 444 insertions(+), 4 deletions(-) create mode 100644 ipa_xmlrpc/ipaxmlrpc.py diff --git a/ipa_xmlrpc/ipaxmlrpc.py b/ipa_xmlrpc/ipaxmlrpc.py new file mode 100644 index 00000000..d447614e --- /dev/null +++ b/ipa_xmlrpc/ipaxmlrpc.py @@ -0,0 +1,399 @@ +# mod_python script + +# ipaxmlrpc - an XMLRPC interface for ipa. +# Copyright (c) 2007 Red Hat +# +# IPA is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; +# version 2.1 of the License. +# +# This software is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this software; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +# +# Based on kojixmlrpc - an XMLRPC interface for koji by +# Mike McLean +# +# Authors: +# Rob Crittenden + +import sys + + +import time +import traceback +import pprint +from xmlrpclib import Marshaller,loads,dumps,Fault +from mod_python import apache +import logging + +import ldap +from ipalib import api +from ipalib import config +from ipa_server import conn +from ipa_server.servercore import context +from ipa_server.servercore import ipautil +import ipalib.load_plugins +from ipalib.util import xmlrpc_unmarshal + +import string +import base64 + +# Global list of available functions +gfunctions = {} + +# +# An override so we can base64 encode all outgoing values. +# This is set by calling: Marshaller._Marshaller__dump = xmlrpclib_dump +# +# Not currently used. +# +def xmlrpclib_escape(s, replace = string.replace): + """ + xmlrpclib only handles certain characters. Lets encode the whole + blob + """ + + return base64.encodestring(s) + +def xmlrpclib_dump(self, value, write): + """ + xmlrpclib cannot marshal instances of subclasses of built-in + types. This function overrides xmlrpclib.Marshaller.__dump so that + any value that is an instance of one of its acceptable types is + marshalled as that type. + + xmlrpclib also cannot handle invalid 7-bit control characters. See + above. + """ + + # Use our escape function + args = [self, value, write] + if isinstance(value, (str, unicode)): + args.append(xmlrpclib_escape) + + try: + # Try for an exact match first + f = self.dispatch[type(value)] + except KeyError: + # Try for an isinstance() match + for Type, f in self.dispatch.iteritems(): + if isinstance(value, Type): + f(*args) + return + raise TypeError, "cannot marshal %s objects" % type(value) + else: + f(*args) + +def register_function(function, name = None): + if name is None: + name = function.__name__ + gfunctions[name] = function + +class ModXMLRPCRequestHandler(object): + """Simple XML-RPC handler for mod_python environment""" + + def __init__(self): + global gfunctions + + self.funcs = gfunctions + self.traceback = False + #introspection functions + self.register_function(self.ping, name="ping") + self.register_function(self.list_api, name="_listapi") + self.register_function(self.system_listMethods, name="system.listMethods") + self.register_function(self.system_methodSignature, name="system.methodSignature") + self.register_function(self.system_methodHelp, name="system.methodHelp") + self.register_function(self.multiCall) + + def register_function(self, function, name = None): + if name is None: + name = function.__name__ + self.funcs[name] = function + + def register_module(self, instance, prefix=None): + """Register all the public functions in an instance with prefix prepended + + For example + h.register_module(exports,"pub.sys") + will register the methods of exports with names like + pub.sys.method1 + pub.sys.method2 + ...etc + """ + for name in dir(instance): + if name.startswith('_'): + continue + function = getattr(instance, name) + if not callable(function): + continue + if prefix is not None: + name = "%s.%s" %(prefix,name) + self.register_function(function, name=name) + + def register_instance(self,instance): + self.register_module(instance) + + def _marshaled_dispatch(self, data, req): + """Dispatches an XML-RPC method from marshalled (XML) data.""" + + params, method = loads(data) + pythonopts = req.get_options() + + # Populate the Apache environment variables + req.add_common_vars() + + context.opts['remoteuser'] = req.user + + if req.subprocess_env.get("KRB5CCNAME") is not None: + krbccache = req.subprocess_env.get("KRB5CCNAME") + else: + response = dumps(Fault(5, "Did not receive Kerberos credentials.")) + return response + + debuglevel = logging.INFO + if pythonopts.get("IPADebug"): + context.opts['ipadebug'] = pythonopts.get("IPADebug").lower() + + if context.opts['ipadebug'] == "on": + debuglevel = logging.DEBUG + + if not context.opts.get('ipadebug'): + context.opts['ipadebug'] = "off" + + logging.basicConfig(level=debuglevel, + format='[%(asctime)s] [%(levelname)s] %(message)s', + datefmt='%a %b %d %H:%M:%S %Y', + stream=sys.stderr) + + logging.info("Interpreter: %s" % req.interpreter) + + +# if opts['ipadebug'] == "on": +# for o in opts: +# logging.debug("IPA: setting option %s: %s" % (o, opts[o])) +# for e in req.subprocess_env: +# logging.debug("IPA: environment %s: %s" % (e, req.subprocess_env[e])) + + # FIXME: don't hardcode server and port + context.conn = conn.IPAConn("localhost", 389, krbccache, context.opts.get('ipadebug')) + + start = time.time() + # generate response + try: + response = self._dispatch(method, params) + # wrap response in a singleton tuple + response = (response,) + response = dumps(response, methodresponse=1, allow_none=1) + except Fault, e: + response = dumps(Fault(e.faultCode, e.faultString)) + except: + self.traceback = True + # report exception back to server + e_class, e = sys.exc_info()[:2] + faultCode = getattr(e_class,'faultCode',1) + tb_str = ''.join(traceback.format_exception(*sys.exc_info())) + faultString = tb_str + response = dumps(Fault(faultCode, faultString)) + + return response + + def _dispatch(self,method,params): + logging.info("functions") + for f in self.funcs.keys(): + logging.info("%s" % f) + logging.info( "Environment variables:") + for var in api.env: + val = api.env[var] + if var is 'server': + logging.info( " Servers:") + for item in api.env.server: + logging.info(" %s" % item) + else: + logging.info( " %s: %s" % (var, val)) + func = self.funcs.get(method,None) + if func is None: + raise Fault(1, "Invalid method: %s" % method) + + (args, kw) = xmlrpc_unmarshal(*params) + + # FIXME: need to convert binary data somewhere + # args = list(ipautil.unwrap_binary_data(params)) + + ret = func(*args, **kw) + + return ipautil.wrap_binary_data(ret) + + def multiCall(self, calls): + """Execute a multicall. Execute each method call in the calls list, collecting + results and errors, and return those as a list.""" + results = [] + for call in calls: + try: + result = self._dispatch(call['methodName'], call['params']) + except Fault, fault: + results.append({'faultCode': fault.faultCode, 'faultString': fault.faultString}) + except: + # transform unknown exceptions into XML-RPC Faults + # don't create a reference to full traceback since this creates + # a circular reference. + exc_type, exc_value = sys.exc_info()[:2] + faultCode = getattr(exc_type, 'faultCode', 1) + faultString = ', '.join(exc_value.args) + trace = traceback.format_exception(*sys.exc_info()) + # traceback is not part of the multicall spec, but we include it for debugging purposes + results.append({'faultCode': faultCode, 'faultString': faultString, 'traceback': trace}) + else: + results.append([result]) + + return results + + def list_api(self): + funcs = [] + for name,func in self.funcs.items(): + #the keys in self.funcs determine the name of the method as seen over xmlrpc + #func.__name__ might differ (e.g. for dotted method names) + args = self._getFuncArgs(func) + funcs.append({'name': name, + 'doc': func.__doc__, + 'args': args}) + return funcs + + def ping(self): + """Simple test to see if the XML-RPC is up and active.""" + return "pong" + + def _getFuncArgs(self, func): + args = [] + for x in range(0, func.func_code.co_argcount): + if x == 0 and func.func_code.co_varnames[x] == "self": + continue + # opts is a name we tack on internally. Don't publish it. + if func.func_code.co_varnames[x] == "opts": + continue + if func.func_defaults and func.func_code.co_argcount - x <= len(func.func_defaults): + args.append((func.func_code.co_varnames[x], func.func_defaults[x - func.func_code.co_argcount + len(func.func_defaults)])) + else: + args.append(func.func_code.co_varnames[x]) + return args + + def system_listMethods(self): + return self.funcs.keys() + + def system_methodSignature(self, method): + #it is not possible to autogenerate this data + return 'signatures not supported' + + def system_methodHelp(self, method): + func = self.funcs.get(method) + if func is None: + return "" + arglist = [] + for arg in self._getFuncArgs(func): + if isinstance(arg,str): + arglist.append(arg) + else: + arglist.append('%s=%s' % (arg[0], arg[1])) + ret = '%s(%s)' % (method, ", ".join(arglist)) + if func.__doc__: + ret += "\ndescription: %s" % func.__doc__ + return ret + + def handle_request(self,req): + """Handle a single XML-RPC request""" + + # XMLRPC uses POST only. Reject anything else + if req.method != 'POST': + req.allow_methods(['POST'],1) + raise apache.SERVER_RETURN, apache.HTTP_METHOD_NOT_ALLOWED + + # The LDAP connection pool is not thread-safe. Avoid problems and + # force the forked model for now. + if apache.mpm_query(apache.AP_MPMQ_IS_THREADED): + response = dumps(Fault(3, "Apache must use the forked model")) + else: + response = self._marshaled_dispatch(req.read(), req) + + req.content_type = "text/xml" + req.set_content_length(len(response)) + req.write(response) + + +# +# mod_python handler +# + +def handler(req, profiling=False): + h = ModXMLRPCRequestHandler() + + if profiling: + import profile, pstats, StringIO, tempfile + global _profiling_req + _profiling_req = req + temp = tempfile.NamedTemporaryFile() + profile.run("import ipxmlrpc; ipaxmlrpc.handler(ipaxmlrpc._profiling_req, False)", temp.name) + stats = pstats.Stats(temp.name) + strstream = StringIO.StringIO() + sys.stdout = strstream + stats.sort_stats("time") + stats.print_stats() + req.write("
" + strstream.getvalue() + "
") + _profiling_req = None + else: + context.opts = req.get_options() + context.reqs = req + try: + h.handle_request(req) + finally: + # Clean up any per-request data and connections + for k in context.__dict__.keys(): + del context.__dict__[k] + + return apache.OK + +def setup_logger(level): + """Make a global logging object.""" + l = logging.getLogger() + l.setLevel(level) + h = logging.StreamHandler() + f = logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s") + h.setFormatter(f) + l.addHandler(h) + + return + +def load_modules(): + """Load all plugins and register the XML-RPC functions we provide. + + Called by mod_python PythonImport + + PythonImport /path/to/ipaxmlrpc.py::load_modules main_interpreter + ... + PythonInterpreter main_interpreter + PythonHandler ipaxmlrpc + """ + + # setup up the logger with a DEBUG level. It may get reset to INFO + # once we start processing requests. We don't have access to the + # Apache configuration yet. + setup_logger(logging.DEBUG) + + api.finalize() + + # Initialize our environment + env_dict = config.read_config() + env_dict['server_context'] = True + api.env.update(config.generate_env(env_dict)) + + # Get and register all the methods + for cmd in api.Command: + logging.debug("registering XML-RPC call %s" % cmd) + register_function(api.Command[cmd], cmd) + + return diff --git a/ipalib/errors.py b/ipalib/errors.py index f1c9e26e..36df0690 100644 --- a/ipalib/errors.py +++ b/ipalib/errors.py @@ -23,6 +23,8 @@ All custom errors raised by `ipalib` package. Also includes a few utility functions for raising exceptions. """ +IPA_ERROR_BASE = 1000 + TYPE_FORMAT = '%s: need a %r; got %r' def raise_TypeError(value, type_, name): diff --git a/ipalib/plugins/b_xmlrpc.py b/ipalib/plugins/b_xmlrpc.py index db2af1ab..9fe5b133 100644 --- a/ipalib/plugins/b_xmlrpc.py +++ b/ipalib/plugins/b_xmlrpc.py @@ -1,5 +1,6 @@ # Authors: # Jason Gerard DeRose +# Rob Crittenden # # Copyright (C) 2008 Red Hat # see file 'COPYING' for use and warranty information @@ -26,6 +27,8 @@ This provides a lightwieght XML-RPC client using Python standard library import xmlrpclib import socket +import httplib +import kerberos from ipalib.backend import Backend from ipalib.util import xmlrpc_marshal from ipalib import api @@ -38,7 +41,12 @@ class xmlrpc(Backend): def get_client(self, verbose=False): # FIXME: The server uri should come from self.api.env.server_uri - return xmlrpclib.ServerProxy('http://localhost:8888', verbose=verbose) + if api.env.get('kerberos'): + server = api.env.server.next() + if verbose: print "Connecting to %s" % server + return xmlrpclib.ServerProxy('https://%s/ipa/xml' % server, transport=KerbTransport(), verbose=verbose) + else: + return xmlrpclib.ServerProxy('http://localhost:8888', verbose=verbose) def forward_call(self, name, *args, **kw): """ @@ -54,10 +62,41 @@ class xmlrpc(Backend): except xmlrpclib.Fault, e: err = errors.convertFault(e) code = getattr(err,'faultCode',None) - if code: - print "%s: %s" % (code, getattr(err,'__doc__','')) - else: + faultString = getattr(err,'faultString',None) + if not code: raise err + if code < errors.IPA_ERROR_BASE: + print "%s: %s" % (code, faultString) + else: + print "%s: %s" % (code, getattr(err,'__doc__','')) return {} api.register(xmlrpc) + +class KerbTransport(xmlrpclib.SafeTransport): + """Handles Kerberos Negotiation authentication to an XML-RPC server.""" + + def get_host_info(self, host): + + host, extra_headers, x509 = xmlrpclib.Transport.get_host_info(self, host) + + # Set the remote host principal + h = host + hostinfo = h.split(':') + service = "HTTP@" + hostinfo[0] + + try: + rc, vc = kerberos.authGSSClientInit(service); + except kerberos.GSSError, e: + raise kerberos.GSSError(e) + + try: + kerberos.authGSSClientStep(vc, ""); + except kerberos.GSSError, e: + raise kerberos.GSSError(e) + + extra_headers = [ + ("Authorization", "negotiate %s" % kerberos.authGSSClientResponse(vc) ) + ] + + return host, extra_headers, x509 -- cgit