From 2c6fc760919cc214413874d60489e3643b639692 Mon Sep 17 00:00:00 2001 From: Brad Bishop Date: Mon, 29 Aug 2016 15:53:25 -0400 Subject: Split server and application Provide the REST server as a python module, enabling it to be hosted by any WSGI provider. Provide a Rocket startup script with configurable WSGI application. Change-Id: I1a9c25b10c33b08dfb8f60dc6c33aaf727562a9f Signed-off-by: Brad Bishop --- module/obmc/wsgi/apps/rest_dbus.py | 749 +++++++++++++++++++++++++++++++++++++ 1 file changed, 749 insertions(+) create mode 100644 module/obmc/wsgi/apps/rest_dbus.py (limited to 'module/obmc/wsgi/apps/rest_dbus.py') diff --git a/module/obmc/wsgi/apps/rest_dbus.py b/module/obmc/wsgi/apps/rest_dbus.py new file mode 100644 index 0000000..246396c --- /dev/null +++ b/module/obmc/wsgi/apps/rest_dbus.py @@ -0,0 +1,749 @@ +# Contributors Listed Below - COPYRIGHT 2016 +# [+] International Business Machines Corp. +# +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +# implied. See the License for the specific language governing +# permissions and limitations under the License. + +import os +import dbus +import dbus.exceptions +import json +from xml.etree import ElementTree +from bottle import Bottle, abort, request, response, JSONPlugin, HTTPError +import obmc.utils.misc +from obmc.dbuslib.introspection import IntrospectionNodeParser +import obmc.mapper +import spwd +import grp +import crypt + +DBUS_UNKNOWN_INTERFACE = 'org.freedesktop.UnknownInterface' +DBUS_UNKNOWN_INTERFACE_ERROR = 'org.freedesktop.DBus.Error.UnknownInterface' +DBUS_UNKNOWN_METHOD = 'org.freedesktop.DBus.Error.UnknownMethod' +DBUS_INVALID_ARGS = 'org.freedesktop.DBus.Error.InvalidArgs' +DBUS_TYPE_ERROR = 'org.freedesktop.DBus.Python.TypeError' +DELETE_IFACE = 'org.openbmc.Object.Delete' + +_4034_msg = "The specified %s cannot be %s: '%s'" + + +def valid_user(session, *a, **kw): + ''' Authorization plugin callback that checks + that the user is logged in. ''' + if session is None: + abort(403, 'Login required') + + +class UserInGroup: + ''' Authorization plugin callback that checks that the user is logged in + and a member of a group. ''' + def __init__(self, group): + self.group = group + + def __call__(self, session, *a, **kw): + valid_user(session, *a, **kw) + res = False + + try: + res = session['user'] in grp.getgrnam(self.group)[3] + except KeyError: + pass + + if not res: + abort(403, 'Insufficient access') + + +class RouteHandler(object): + _require_auth = obmc.utils.misc.makelist(valid_user) + + def __init__(self, app, bus, verbs, rules): + self.app = app + self.bus = bus + self.mapper = obmc.mapper.Mapper(bus) + self._verbs = obmc.utils.misc.makelist(verbs) + self._rules = rules + self.intf_match = obmc.utils.misc.org_dot_openbmc_match + + def _setup(self, **kw): + request.route_data = {} + if request.method in self._verbs: + return self.setup(**kw) + else: + self.find(**kw) + raise HTTPError( + 405, "Method not allowed.", Allow=','.join(self._verbs)) + + def __call__(self, **kw): + return getattr(self, 'do_' + request.method.lower())(**kw) + + def install(self): + self.app.route( + self._rules, callback=self, + method=['GET', 'PUT', 'PATCH', 'POST', 'DELETE']) + + @staticmethod + def try_mapper_call(f, callback=None, **kw): + try: + return f(**kw) + except dbus.exceptions.DBusException, e: + if e.get_dbus_name() != obmc.mapper.MAPPER_NOT_FOUND: + raise + if callback is None: + def callback(e, **kw): + abort(404, str(e)) + + callback(e, **kw) + + @staticmethod + def try_properties_interface(f, *a): + try: + return f(*a) + except dbus.exceptions.DBusException, e: + if DBUS_UNKNOWN_INTERFACE in e.get_dbus_message(): + # interface doesn't have any properties + return None + if DBUS_UNKNOWN_INTERFACE_ERROR in e.get_dbus_name(): + # interface doesn't have any properties + return None + if DBUS_UNKNOWN_METHOD == e.get_dbus_name(): + # properties interface not implemented at all + return None + raise + + +class DirectoryHandler(RouteHandler): + verbs = 'GET' + rules = '/' + + def __init__(self, app, bus): + super(DirectoryHandler, self).__init__( + app, bus, self.verbs, self.rules) + + def find(self, path='/'): + return self.try_mapper_call( + self.mapper.get_subtree_paths, path=path, depth=1) + + def setup(self, path='/'): + request.route_data['map'] = self.find(path) + + def do_get(self, path='/'): + return request.route_data['map'] + + +class ListNamesHandler(RouteHandler): + verbs = 'GET' + rules = ['/list', '/list'] + + def __init__(self, app, bus): + super(ListNamesHandler, self).__init__( + app, bus, self.verbs, self.rules) + + def find(self, path='/'): + return self.try_mapper_call( + self.mapper.get_subtree, path=path).keys() + + def setup(self, path='/'): + request.route_data['map'] = self.find(path) + + def do_get(self, path='/'): + return request.route_data['map'] + + +class ListHandler(RouteHandler): + verbs = 'GET' + rules = ['/enumerate', '/enumerate'] + + def __init__(self, app, bus): + super(ListHandler, self).__init__( + app, bus, self.verbs, self.rules) + + def find(self, path='/'): + return self.try_mapper_call( + self.mapper.get_subtree, path=path) + + def setup(self, path='/'): + request.route_data['map'] = self.find(path) + + def do_get(self, path='/'): + return {x: y for x, y in self.mapper.enumerate_subtree( + path, + mapper_data=request.route_data['map']).dataitems()} + + +class MethodHandler(RouteHandler): + verbs = 'POST' + rules = '/action/' + request_type = list + + def __init__(self, app, bus): + super(MethodHandler, self).__init__( + app, bus, self.verbs, self.rules) + + def find(self, path, method): + busses = self.try_mapper_call( + self.mapper.get_object, path=path) + for items in busses.iteritems(): + m = self.find_method_on_bus(path, method, *items) + if m: + return m + + abort(404, _4034_msg % ('method', 'found', method)) + + def setup(self, path, method): + request.route_data['method'] = self.find(path, method) + + def do_post(self, path, method): + try: + if request.parameter_list: + return request.route_data['method'](*request.parameter_list) + else: + return request.route_data['method']() + + except dbus.exceptions.DBusException, e: + if e.get_dbus_name() == DBUS_INVALID_ARGS: + abort(400, str(e)) + if e.get_dbus_name() == DBUS_TYPE_ERROR: + abort(400, str(e)) + raise + + @staticmethod + def find_method_in_interface(method, obj, interface, methods): + if methods is None: + return None + + method = obmc.utils.misc.find_case_insensitive(method, methods.keys()) + if method is not None: + iface = dbus.Interface(obj, interface) + return iface.get_dbus_method(method) + + def find_method_on_bus(self, path, method, bus, interfaces): + obj = self.bus.get_object(bus, path, introspect=False) + iface = dbus.Interface(obj, dbus.INTROSPECTABLE_IFACE) + data = iface.Introspect() + parser = IntrospectionNodeParser( + ElementTree.fromstring(data), + intf_match=obmc.utils.misc.ListMatch(interfaces)) + for x, y in parser.get_interfaces().iteritems(): + m = self.find_method_in_interface( + method, obj, x, y.get('method')) + if m: + return m + + +class PropertyHandler(RouteHandler): + verbs = ['PUT', 'GET'] + rules = '/attr/' + + def __init__(self, app, bus): + super(PropertyHandler, self).__init__( + app, bus, self.verbs, self.rules) + + def find(self, path, prop): + self.app.instance_handler.setup(path) + obj = self.app.instance_handler.do_get(path) + try: + obj[prop] + except KeyError, e: + if request.method == 'PUT': + abort(403, _4034_msg % ('property', 'created', str(e))) + else: + abort(404, _4034_msg % ('property', 'found', str(e))) + + return {path: obj} + + def setup(self, path, prop): + request.route_data['obj'] = self.find(path, prop) + + def do_get(self, path, prop): + return request.route_data['obj'][path][prop] + + def do_put(self, path, prop, value=None): + if value is None: + value = request.parameter_list + + prop, iface, properties_iface = self.get_host_interface( + path, prop, request.route_data['map'][path]) + try: + properties_iface.Set(iface, prop, value) + except ValueError, e: + abort(400, str(e)) + except dbus.exceptions.DBusException, e: + if e.get_dbus_name() == DBUS_INVALID_ARGS: + abort(403, str(e)) + raise + + def get_host_interface(self, path, prop, bus_info): + for bus, interfaces in bus_info.iteritems(): + obj = self.bus.get_object(bus, path, introspect=True) + properties_iface = dbus.Interface( + obj, dbus_interface=dbus.PROPERTIES_IFACE) + + info = self.get_host_interface_on_bus( + path, prop, properties_iface, bus, interfaces) + if info is not None: + prop, iface = info + return prop, iface, properties_iface + + def get_host_interface_on_bus(self, path, prop, iface, bus, interfaces): + for i in interfaces: + properties = self.try_properties_interface(iface.GetAll, i) + if properties is None: + continue + prop = obmc.utils.misc.find_case_insensitive(prop, properties.keys()) + if prop is None: + continue + return prop, i + + +class SchemaHandler(RouteHandler): + verbs = ['GET'] + rules = '/schema' + + def __init__(self, app, bus): + super(SchemaHandler, self).__init__( + app, bus, self.verbs, self.rules) + + def find(self, path): + return self.try_mapper_call( + self.mapper.get_object, + path=path) + + def setup(self, path): + request.route_data['map'] = self.find(path) + + def do_get(self, path): + schema = {} + for x in request.route_data['map'].iterkeys(): + obj = self.bus.get_object(x, path, introspect=False) + iface = dbus.Interface(obj, dbus.INTROSPECTABLE_IFACE) + data = iface.Introspect() + parser = IntrospectionNodeParser( + ElementTree.fromstring(data)) + for x, y in parser.get_interfaces().iteritems(): + schema[x] = y + + return schema + + +class InstanceHandler(RouteHandler): + verbs = ['GET', 'PUT', 'DELETE'] + rules = '' + request_type = dict + + def __init__(self, app, bus): + super(InstanceHandler, self).__init__( + app, bus, self.verbs, self.rules) + + def find(self, path, callback=None): + return {path: self.try_mapper_call( + self.mapper.get_object, + callback, + path=path)} + + def setup(self, path): + callback = None + if request.method == 'PUT': + def callback(e, **kw): + abort(403, _4034_msg % ('resource', 'created', path)) + + if request.route_data.get('map') is None: + request.route_data['map'] = self.find(path, callback) + + def do_get(self, path): + return self.mapper.enumerate_object( + path, + mapper_data=request.route_data['map']) + + def do_put(self, path): + # make sure all properties exist in the request + obj = set(self.do_get(path).keys()) + req = set(request.parameter_list.keys()) + + diff = list(obj.difference(req)) + if diff: + abort(403, _4034_msg % ( + 'resource', 'removed', '%s/attr/%s' % (path, diff[0]))) + + diff = list(req.difference(obj)) + if diff: + abort(403, _4034_msg % ( + 'resource', 'created', '%s/attr/%s' % (path, diff[0]))) + + for p, v in request.parameter_list.iteritems(): + self.app.property_handler.do_put( + path, p, v) + + def do_delete(self, path): + for bus_info in request.route_data['map'][path].iteritems(): + if self.bus_missing_delete(path, *bus_info): + abort(403, _4034_msg % ('resource', 'removed', path)) + + for bus in request.route_data['map'][path].iterkeys(): + self.delete_on_bus(path, bus) + + def bus_missing_delete(self, path, bus, interfaces): + return DELETE_IFACE not in interfaces + + def delete_on_bus(self, path, bus): + obj = self.bus.get_object(bus, path, introspect=False) + delete_iface = dbus.Interface( + obj, dbus_interface=DELETE_IFACE) + delete_iface.Delete() + + +class SessionHandler(MethodHandler): + ''' Handles the /login and /logout routes, manages + server side session store and session cookies. ''' + + rules = ['/login', '/logout'] + login_str = "User '%s' logged %s" + bad_passwd_str = "Invalid username or password" + no_user_str = "No user logged in" + bad_json_str = "Expecting request format { 'data': " \ + "[, ] }, got '%s'" + _require_auth = None + MAX_SESSIONS = 16 + + def __init__(self, app, bus): + super(SessionHandler, self).__init__( + app, bus) + self.hmac_key = os.urandom(128) + self.session_store = [] + + @staticmethod + def authenticate(username, clear): + try: + encoded = spwd.getspnam(username)[1] + return encoded == crypt.crypt(clear, encoded) + except KeyError: + return False + + def invalidate_session(self, session): + try: + self.session_store.remove(session) + except ValueError: + pass + + def new_session(self): + sid = os.urandom(32) + if self.MAX_SESSIONS <= len(self.session_store): + self.session_store.pop() + self.session_store.insert(0, {'sid': sid}) + + return self.session_store[0] + + def get_session(self, sid): + sids = [x['sid'] for x in self.session_store] + try: + return self.session_store[sids.index(sid)] + except ValueError: + return None + + def get_session_from_cookie(self): + return self.get_session( + request.get_cookie( + 'sid', secret=self.hmac_key)) + + def do_post(self, **kw): + if request.path == '/login': + return self.do_login(**kw) + else: + return self.do_logout(**kw) + + def do_logout(self, **kw): + session = self.get_session_from_cookie() + if session is not None: + user = session['user'] + self.invalidate_session(session) + response.delete_cookie('sid') + return self.login_str % (user, 'out') + + return self.no_user_str + + def do_login(self, **kw): + session = self.get_session_from_cookie() + if session is not None: + return self.login_str % (session['user'], 'in') + + if len(request.parameter_list) != 2: + abort(400, self.bad_json_str % (request.json)) + + if not self.authenticate(*request.parameter_list): + return self.bad_passwd_str + + user = request.parameter_list[0] + session = self.new_session() + session['user'] = user + response.set_cookie( + 'sid', session['sid'], secret=self.hmac_key, + secure=True, + httponly=True) + return self.login_str % (user, 'in') + + def find(self, **kw): + pass + + def setup(self, **kw): + pass + + +class AuthorizationPlugin(object): + ''' Invokes an optional list of authorization callbacks. ''' + + name = 'authorization' + api = 2 + + class Compose: + def __init__(self, validators, callback, session_mgr): + self.validators = validators + self.callback = callback + self.session_mgr = session_mgr + + def __call__(self, *a, **kw): + sid = request.get_cookie('sid', secret=self.session_mgr.hmac_key) + session = self.session_mgr.get_session(sid) + for x in self.validators: + x(session, *a, **kw) + + return self.callback(*a, **kw) + + def apply(self, callback, route): + undecorated = route.get_undecorated_callback() + if not isinstance(undecorated, RouteHandler): + return callback + + auth_types = getattr( + undecorated, '_require_auth', None) + if not auth_types: + return callback + + return self.Compose( + auth_types, callback, undecorated.app.session_handler) + + +class JsonApiRequestPlugin(object): + ''' Ensures request content satisfies the OpenBMC json api format. ''' + name = 'json_api_request' + api = 2 + + error_str = "Expecting request format { 'data': }, got '%s'" + type_error_str = "Unsupported Content-Type: '%s'" + json_type = "application/json" + request_methods = ['PUT', 'POST', 'PATCH'] + + @staticmethod + def content_expected(): + return request.method in JsonApiRequestPlugin.request_methods + + def validate_request(self): + if request.content_length > 0 and \ + request.content_type != self.json_type: + abort(415, self.type_error_str % request.content_type) + + try: + request.parameter_list = request.json.get('data') + except ValueError, e: + abort(400, str(e)) + except (AttributeError, KeyError, TypeError): + abort(400, self.error_str % request.json) + + def apply(self, callback, route): + verbs = getattr( + route.get_undecorated_callback(), '_verbs', None) + if verbs is None: + return callback + + if not set(self.request_methods).intersection(verbs): + return callback + + def wrap(*a, **kw): + if self.content_expected(): + self.validate_request() + return callback(*a, **kw) + + return wrap + + +class JsonApiRequestTypePlugin(object): + ''' Ensures request content type satisfies the OpenBMC json api format. ''' + name = 'json_api_method_request' + api = 2 + + error_str = "Expecting request format { 'data': %s }, got '%s'" + + def apply(self, callback, route): + request_type = getattr( + route.get_undecorated_callback(), 'request_type', None) + if request_type is None: + return callback + + def validate_request(): + if not isinstance(request.parameter_list, request_type): + abort(400, self.error_str % (str(request_type), request.json)) + + def wrap(*a, **kw): + if JsonApiRequestPlugin.content_expected(): + validate_request() + return callback(*a, **kw) + + return wrap + + +class JsonApiResponsePlugin(object): + ''' Emits normal responses in the OpenBMC json api format. ''' + name = 'json_api_response' + api = 2 + + def apply(self, callback, route): + def wrap(*a, **kw): + resp = {'data': callback(*a, **kw)} + resp['status'] = 'ok' + resp['message'] = response.status_line + return resp + return wrap + + +class JsonApiErrorsPlugin(object): + ''' Emits error responses in the OpenBMC json api format. ''' + name = 'json_api_errors' + api = 2 + + def __init__(self, **kw): + self.app = None + self.function_type = None + self.original = None + self.json_opts = { + x: y for x, y in kw.iteritems() + if x in ['indent', 'sort_keys']} + + def setup(self, app): + self.app = app + self.function_type = type(app.default_error_handler) + self.original = app.default_error_handler + self.app.default_error_handler = self.function_type( + self.json_errors, app, Bottle) + + def apply(self, callback, route): + return callback + + def close(self): + self.app.default_error_handler = self.function_type( + self.original, self.app, Bottle) + + def json_errors(self, res, error): + response_object = {'status': 'error', 'data': {}} + response_object['message'] = error.status_line + response_object['data']['description'] = str(error.body) + if error.status_code == 500: + response_object['data']['exception'] = repr(error.exception) + response_object['data']['traceback'] = error.traceback.splitlines() + + json_response = json.dumps(response_object, **self.json_opts) + response.content_type = 'application/json' + return json_response + + +class JsonpPlugin(JsonApiErrorsPlugin): + ''' Json javascript wrapper. ''' + name = 'jsonp' + api = 2 + + def __init__(self, **kw): + super(JsonpPlugin, self).__init__(**kw) + + @staticmethod + def to_jsonp(json): + jwrapper = request.query.callback or None + if(jwrapper): + response.set_header('Content-Type', 'application/javascript') + json = jwrapper + '(' + json + ');' + return json + + def apply(self, callback, route): + def wrap(*a, **kw): + return self.to_jsonp(callback(*a, **kw)) + return wrap + + def json_errors(self, res, error): + json = super(JsonpPlugin, self).json_errors(res, error) + return self.to_jsonp(json) + + +class App(Bottle): + def __init__(self): + super(App, self).__init__(autojson=False) + self.bus = dbus.SystemBus() + self.mapper = obmc.mapper.Mapper(self.bus) + + self.install_hooks() + self.install_plugins() + self.create_handlers() + self.install_handlers() + + def install_plugins(self): + # install json api plugins + json_kw = {'indent': 2, 'sort_keys': True} + self.install(AuthorizationPlugin()) + self.install(JsonpPlugin(**json_kw)) + self.install(JSONPlugin(**json_kw)) + self.install(JsonApiResponsePlugin()) + self.install(JsonApiRequestPlugin()) + self.install(JsonApiRequestTypePlugin()) + + def install_hooks(self): + self.real_router_match = self.router.match + self.router.match = self.custom_router_match + self.add_hook('before_request', self.strip_extra_slashes) + + def create_handlers(self): + # create route handlers + self.session_handler = SessionHandler(self, self.bus) + self.directory_handler = DirectoryHandler(self, self.bus) + self.list_names_handler = ListNamesHandler(self, self.bus) + self.list_handler = ListHandler(self, self.bus) + self.method_handler = MethodHandler(self, self.bus) + self.property_handler = PropertyHandler(self, self.bus) + self.schema_handler = SchemaHandler(self, self.bus) + self.instance_handler = InstanceHandler(self, self.bus) + + def install_handlers(self): + self.session_handler.install() + self.directory_handler.install() + self.list_names_handler.install() + self.list_handler.install() + self.method_handler.install() + self.property_handler.install() + self.schema_handler.install() + # this has to come last, since it matches everything + self.instance_handler.install() + + def custom_router_match(self, environ): + ''' The built-in Bottle algorithm for figuring out if a 404 or 405 is + needed doesn't work for us since the instance rules match + everything. This monkey-patch lets the route handler figure + out which response is needed. This could be accomplished + with a hook but that would require calling the router match + function twice. + ''' + route, args = self.real_router_match(environ) + if isinstance(route.callback, RouteHandler): + route.callback._setup(**args) + + return route, args + + @staticmethod + def strip_extra_slashes(): + path = request.environ['PATH_INFO'] + trailing = ("", "/")[path[-1] == '/'] + parts = filter(bool, path.split('/')) + request.environ['PATH_INFO'] = '/' + '/'.join(parts) + trailing -- cgit v1.2.1