1# Contributors Listed Below - COPYRIGHT 2016
2# [+] International Business Machines Corp.
3#
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9#     http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
14# implied. See the License for the specific language governing
15# permissions and limitations under the License.
16
17import os
18import dbus
19import dbus.exceptions
20import json
21from xml.etree import ElementTree
22from bottle import Bottle, abort, request, response, JSONPlugin, HTTPError
23import obmc.utils.misc
24from obmc.dbuslib.introspection import IntrospectionNodeParser
25import obmc.mapper
26import spwd
27import grp
28import crypt
29
30DBUS_UNKNOWN_INTERFACE = 'org.freedesktop.UnknownInterface'
31DBUS_UNKNOWN_INTERFACE_ERROR = 'org.freedesktop.DBus.Error.UnknownInterface'
32DBUS_UNKNOWN_METHOD = 'org.freedesktop.DBus.Error.UnknownMethod'
33DBUS_INVALID_ARGS = 'org.freedesktop.DBus.Error.InvalidArgs'
34DBUS_TYPE_ERROR = 'org.freedesktop.DBus.Python.TypeError'
35DELETE_IFACE = 'org.openbmc.Object.Delete'
36
37_4034_msg = "The specified %s cannot be %s: '%s'"
38
39
40def valid_user(session, *a, **kw):
41    ''' Authorization plugin callback that checks
42    that the user is logged in. '''
43    if session is None:
44        abort(403, 'Login required')
45
46
47class UserInGroup:
48    ''' Authorization plugin callback that checks that the user is logged in
49    and a member of a group. '''
50    def __init__(self, group):
51        self.group = group
52
53    def __call__(self, session, *a, **kw):
54        valid_user(session, *a, **kw)
55        res = False
56
57        try:
58            res = session['user'] in grp.getgrnam(self.group)[3]
59        except KeyError:
60            pass
61
62        if not res:
63            abort(403, 'Insufficient access')
64
65
66class RouteHandler(object):
67    _require_auth = obmc.utils.misc.makelist(valid_user)
68
69    def __init__(self, app, bus, verbs, rules):
70        self.app = app
71        self.bus = bus
72        self.mapper = obmc.mapper.Mapper(bus)
73        self._verbs = obmc.utils.misc.makelist(verbs)
74        self._rules = rules
75        self.intf_match = obmc.utils.misc.org_dot_openbmc_match
76
77    def _setup(self, **kw):
78        request.route_data = {}
79        if request.method in self._verbs:
80            return self.setup(**kw)
81        else:
82            self.find(**kw)
83            raise HTTPError(
84                405, "Method not allowed.", Allow=','.join(self._verbs))
85
86    def __call__(self, **kw):
87        return getattr(self, 'do_' + request.method.lower())(**kw)
88
89    def install(self):
90        self.app.route(
91            self._rules, callback=self,
92            method=['GET', 'PUT', 'PATCH', 'POST', 'DELETE'])
93
94    @staticmethod
95    def try_mapper_call(f, callback=None, **kw):
96        try:
97            return f(**kw)
98        except dbus.exceptions.DBusException, e:
99            if e.get_dbus_name() != obmc.mapper.MAPPER_NOT_FOUND:
100                raise
101            if callback is None:
102                def callback(e, **kw):
103                    abort(404, str(e))
104
105            callback(e, **kw)
106
107    @staticmethod
108    def try_properties_interface(f, *a):
109        try:
110            return f(*a)
111        except dbus.exceptions.DBusException, e:
112            if DBUS_UNKNOWN_INTERFACE in e.get_dbus_message():
113                # interface doesn't have any properties
114                return None
115            if DBUS_UNKNOWN_INTERFACE_ERROR in e.get_dbus_name():
116                # interface doesn't have any properties
117                return None
118            if DBUS_UNKNOWN_METHOD == e.get_dbus_name():
119                # properties interface not implemented at all
120                return None
121            raise
122
123
124class DirectoryHandler(RouteHandler):
125    verbs = 'GET'
126    rules = '<path:path>/'
127
128    def __init__(self, app, bus):
129        super(DirectoryHandler, self).__init__(
130            app, bus, self.verbs, self.rules)
131
132    def find(self, path='/'):
133        return self.try_mapper_call(
134            self.mapper.get_subtree_paths, path=path, depth=1)
135
136    def setup(self, path='/'):
137        request.route_data['map'] = self.find(path)
138
139    def do_get(self, path='/'):
140        return request.route_data['map']
141
142
143class ListNamesHandler(RouteHandler):
144    verbs = 'GET'
145    rules = ['/list', '<path:path>/list']
146
147    def __init__(self, app, bus):
148        super(ListNamesHandler, self).__init__(
149            app, bus, self.verbs, self.rules)
150
151    def find(self, path='/'):
152        return self.try_mapper_call(
153            self.mapper.get_subtree, path=path).keys()
154
155    def setup(self, path='/'):
156        request.route_data['map'] = self.find(path)
157
158    def do_get(self, path='/'):
159        return request.route_data['map']
160
161
162class ListHandler(RouteHandler):
163    verbs = 'GET'
164    rules = ['/enumerate', '<path:path>/enumerate']
165
166    def __init__(self, app, bus):
167        super(ListHandler, self).__init__(
168            app, bus, self.verbs, self.rules)
169
170    def find(self, path='/'):
171        return self.try_mapper_call(
172            self.mapper.get_subtree, path=path)
173
174    def setup(self, path='/'):
175        request.route_data['map'] = self.find(path)
176
177    def do_get(self, path='/'):
178        return {x: y for x, y in self.mapper.enumerate_subtree(
179                path,
180                mapper_data=request.route_data['map']).dataitems()}
181
182
183class MethodHandler(RouteHandler):
184    verbs = 'POST'
185    rules = '<path:path>/action/<method>'
186    request_type = list
187
188    def __init__(self, app, bus):
189        super(MethodHandler, self).__init__(
190            app, bus, self.verbs, self.rules)
191
192    def find(self, path, method):
193        busses = self.try_mapper_call(
194            self.mapper.get_object, path=path)
195        for items in busses.iteritems():
196            m = self.find_method_on_bus(path, method, *items)
197            if m:
198                return m
199
200        abort(404, _4034_msg % ('method', 'found', method))
201
202    def setup(self, path, method):
203        request.route_data['method'] = self.find(path, method)
204
205    def do_post(self, path, method):
206        try:
207            if request.parameter_list:
208                return request.route_data['method'](*request.parameter_list)
209            else:
210                return request.route_data['method']()
211
212        except dbus.exceptions.DBusException, e:
213            if e.get_dbus_name() == DBUS_INVALID_ARGS:
214                abort(400, str(e))
215            if e.get_dbus_name() == DBUS_TYPE_ERROR:
216                abort(400, str(e))
217            raise
218
219    @staticmethod
220    def find_method_in_interface(method, obj, interface, methods):
221        if methods is None:
222            return None
223
224        method = obmc.utils.misc.find_case_insensitive(method, methods.keys())
225        if method is not None:
226            iface = dbus.Interface(obj, interface)
227            return iface.get_dbus_method(method)
228
229    def find_method_on_bus(self, path, method, bus, interfaces):
230        obj = self.bus.get_object(bus, path, introspect=False)
231        iface = dbus.Interface(obj, dbus.INTROSPECTABLE_IFACE)
232        data = iface.Introspect()
233        parser = IntrospectionNodeParser(
234            ElementTree.fromstring(data),
235            intf_match=obmc.utils.misc.ListMatch(interfaces))
236        for x, y in parser.get_interfaces().iteritems():
237            m = self.find_method_in_interface(
238                method, obj, x, y.get('method'))
239            if m:
240                return m
241
242
243class PropertyHandler(RouteHandler):
244    verbs = ['PUT', 'GET']
245    rules = '<path:path>/attr/<prop>'
246
247    def __init__(self, app, bus):
248        super(PropertyHandler, self).__init__(
249            app, bus, self.verbs, self.rules)
250
251    def find(self, path, prop):
252        self.app.instance_handler.setup(path)
253        obj = self.app.instance_handler.do_get(path)
254        try:
255            obj[prop]
256        except KeyError, e:
257            if request.method == 'PUT':
258                abort(403, _4034_msg % ('property', 'created', str(e)))
259            else:
260                abort(404, _4034_msg % ('property', 'found', str(e)))
261
262        return {path: obj}
263
264    def setup(self, path, prop):
265        request.route_data['obj'] = self.find(path, prop)
266
267    def do_get(self, path, prop):
268        return request.route_data['obj'][path][prop]
269
270    def do_put(self, path, prop, value=None):
271        if value is None:
272            value = request.parameter_list
273
274        prop, iface, properties_iface = self.get_host_interface(
275            path, prop, request.route_data['map'][path])
276        try:
277            properties_iface.Set(iface, prop, value)
278        except ValueError, e:
279            abort(400, str(e))
280        except dbus.exceptions.DBusException, e:
281            if e.get_dbus_name() == DBUS_INVALID_ARGS:
282                abort(403, str(e))
283            raise
284
285    def get_host_interface(self, path, prop, bus_info):
286        for bus, interfaces in bus_info.iteritems():
287            obj = self.bus.get_object(bus, path, introspect=True)
288            properties_iface = dbus.Interface(
289                obj, dbus_interface=dbus.PROPERTIES_IFACE)
290
291            info = self.get_host_interface_on_bus(
292                path, prop, properties_iface, bus, interfaces)
293            if info is not None:
294                prop, iface = info
295                return prop, iface, properties_iface
296
297    def get_host_interface_on_bus(self, path, prop, iface, bus, interfaces):
298        for i in interfaces:
299            properties = self.try_properties_interface(iface.GetAll, i)
300            if properties is None:
301                continue
302            prop = obmc.utils.misc.find_case_insensitive(prop, properties.keys())
303            if prop is None:
304                continue
305            return prop, i
306
307
308class SchemaHandler(RouteHandler):
309    verbs = ['GET']
310    rules = '<path:path>/schema'
311
312    def __init__(self, app, bus):
313        super(SchemaHandler, self).__init__(
314            app, bus, self.verbs, self.rules)
315
316    def find(self, path):
317        return self.try_mapper_call(
318            self.mapper.get_object,
319            path=path)
320
321    def setup(self, path):
322        request.route_data['map'] = self.find(path)
323
324    def do_get(self, path):
325        schema = {}
326        for x in request.route_data['map'].iterkeys():
327            obj = self.bus.get_object(x, path, introspect=False)
328            iface = dbus.Interface(obj, dbus.INTROSPECTABLE_IFACE)
329            data = iface.Introspect()
330            parser = IntrospectionNodeParser(
331                ElementTree.fromstring(data))
332            for x, y in parser.get_interfaces().iteritems():
333                schema[x] = y
334
335        return schema
336
337
338class InstanceHandler(RouteHandler):
339    verbs = ['GET', 'PUT', 'DELETE']
340    rules = '<path:path>'
341    request_type = dict
342
343    def __init__(self, app, bus):
344        super(InstanceHandler, self).__init__(
345            app, bus, self.verbs, self.rules)
346
347    def find(self, path, callback=None):
348        return {path: self.try_mapper_call(
349            self.mapper.get_object,
350            callback,
351            path=path)}
352
353    def setup(self, path):
354        callback = None
355        if request.method == 'PUT':
356            def callback(e, **kw):
357                abort(403, _4034_msg % ('resource', 'created', path))
358
359        if request.route_data.get('map') is None:
360            request.route_data['map'] = self.find(path, callback)
361
362    def do_get(self, path):
363        return self.mapper.enumerate_object(
364            path,
365            mapper_data=request.route_data['map'])
366
367    def do_put(self, path):
368        # make sure all properties exist in the request
369        obj = set(self.do_get(path).keys())
370        req = set(request.parameter_list.keys())
371
372        diff = list(obj.difference(req))
373        if diff:
374            abort(403, _4034_msg % (
375                'resource', 'removed', '%s/attr/%s' % (path, diff[0])))
376
377        diff = list(req.difference(obj))
378        if diff:
379            abort(403, _4034_msg % (
380                'resource', 'created', '%s/attr/%s' % (path, diff[0])))
381
382        for p, v in request.parameter_list.iteritems():
383            self.app.property_handler.do_put(
384                path, p, v)
385
386    def do_delete(self, path):
387        for bus_info in request.route_data['map'][path].iteritems():
388            if self.bus_missing_delete(path, *bus_info):
389                abort(403, _4034_msg % ('resource', 'removed', path))
390
391        for bus in request.route_data['map'][path].iterkeys():
392            self.delete_on_bus(path, bus)
393
394    def bus_missing_delete(self, path, bus, interfaces):
395        return DELETE_IFACE not in interfaces
396
397    def delete_on_bus(self, path, bus):
398        obj = self.bus.get_object(bus, path, introspect=False)
399        delete_iface = dbus.Interface(
400            obj, dbus_interface=DELETE_IFACE)
401        delete_iface.Delete()
402
403
404class SessionHandler(MethodHandler):
405    ''' Handles the /login and /logout routes, manages
406    server side session store and session cookies.  '''
407
408    rules = ['/login', '/logout']
409    login_str = "User '%s' logged %s"
410    bad_passwd_str = "Invalid username or password"
411    no_user_str = "No user logged in"
412    bad_json_str = "Expecting request format { 'data': " \
413        "[<username>, <password>] }, got '%s'"
414    _require_auth = None
415    MAX_SESSIONS = 16
416
417    def __init__(self, app, bus):
418        super(SessionHandler, self).__init__(
419            app, bus)
420        self.hmac_key = os.urandom(128)
421        self.session_store = []
422
423    @staticmethod
424    def authenticate(username, clear):
425        try:
426            encoded = spwd.getspnam(username)[1]
427            return encoded == crypt.crypt(clear, encoded)
428        except KeyError:
429            return False
430
431    def invalidate_session(self, session):
432        try:
433            self.session_store.remove(session)
434        except ValueError:
435            pass
436
437    def new_session(self):
438        sid = os.urandom(32)
439        if self.MAX_SESSIONS <= len(self.session_store):
440            self.session_store.pop()
441        self.session_store.insert(0, {'sid': sid})
442
443        return self.session_store[0]
444
445    def get_session(self, sid):
446        sids = [x['sid'] for x in self.session_store]
447        try:
448            return self.session_store[sids.index(sid)]
449        except ValueError:
450            return None
451
452    def get_session_from_cookie(self):
453        return self.get_session(
454            request.get_cookie(
455                'sid', secret=self.hmac_key))
456
457    def do_post(self, **kw):
458        if request.path == '/login':
459            return self.do_login(**kw)
460        else:
461            return self.do_logout(**kw)
462
463    def do_logout(self, **kw):
464        session = self.get_session_from_cookie()
465        if session is not None:
466            user = session['user']
467            self.invalidate_session(session)
468            response.delete_cookie('sid')
469            return self.login_str % (user, 'out')
470
471        return self.no_user_str
472
473    def do_login(self, **kw):
474        session = self.get_session_from_cookie()
475        if session is not None:
476            return self.login_str % (session['user'], 'in')
477
478        if len(request.parameter_list) != 2:
479            abort(400, self.bad_json_str % (request.json))
480
481        if not self.authenticate(*request.parameter_list):
482            return self.bad_passwd_str
483
484        user = request.parameter_list[0]
485        session = self.new_session()
486        session['user'] = user
487        response.set_cookie(
488            'sid', session['sid'], secret=self.hmac_key,
489            secure=True,
490            httponly=True)
491        return self.login_str % (user, 'in')
492
493    def find(self, **kw):
494        pass
495
496    def setup(self, **kw):
497        pass
498
499
500class AuthorizationPlugin(object):
501    ''' Invokes an optional list of authorization callbacks. '''
502
503    name = 'authorization'
504    api = 2
505
506    class Compose:
507        def __init__(self, validators, callback, session_mgr):
508            self.validators = validators
509            self.callback = callback
510            self.session_mgr = session_mgr
511
512        def __call__(self, *a, **kw):
513            sid = request.get_cookie('sid', secret=self.session_mgr.hmac_key)
514            session = self.session_mgr.get_session(sid)
515            for x in self.validators:
516                x(session, *a, **kw)
517
518            return self.callback(*a, **kw)
519
520    def apply(self, callback, route):
521        undecorated = route.get_undecorated_callback()
522        if not isinstance(undecorated, RouteHandler):
523            return callback
524
525        auth_types = getattr(
526            undecorated, '_require_auth', None)
527        if not auth_types:
528            return callback
529
530        return self.Compose(
531            auth_types, callback, undecorated.app.session_handler)
532
533
534class JsonApiRequestPlugin(object):
535    ''' Ensures request content satisfies the OpenBMC json api format. '''
536    name = 'json_api_request'
537    api = 2
538
539    error_str = "Expecting request format { 'data': <value> }, got '%s'"
540    type_error_str = "Unsupported Content-Type: '%s'"
541    json_type = "application/json"
542    request_methods = ['PUT', 'POST', 'PATCH']
543
544    @staticmethod
545    def content_expected():
546        return request.method in JsonApiRequestPlugin.request_methods
547
548    def validate_request(self):
549        if request.content_length > 0 and \
550                request.content_type != self.json_type:
551            abort(415, self.type_error_str % request.content_type)
552
553        try:
554            request.parameter_list = request.json.get('data')
555        except ValueError, e:
556            abort(400, str(e))
557        except (AttributeError, KeyError, TypeError):
558            abort(400, self.error_str % request.json)
559
560    def apply(self, callback, route):
561        verbs = getattr(
562            route.get_undecorated_callback(), '_verbs', None)
563        if verbs is None:
564            return callback
565
566        if not set(self.request_methods).intersection(verbs):
567            return callback
568
569        def wrap(*a, **kw):
570            if self.content_expected():
571                self.validate_request()
572            return callback(*a, **kw)
573
574        return wrap
575
576
577class JsonApiRequestTypePlugin(object):
578    ''' Ensures request content type satisfies the OpenBMC json api format. '''
579    name = 'json_api_method_request'
580    api = 2
581
582    error_str = "Expecting request format { 'data': %s }, got '%s'"
583
584    def apply(self, callback, route):
585        request_type = getattr(
586            route.get_undecorated_callback(), 'request_type', None)
587        if request_type is None:
588            return callback
589
590        def validate_request():
591            if not isinstance(request.parameter_list, request_type):
592                abort(400, self.error_str % (str(request_type), request.json))
593
594        def wrap(*a, **kw):
595            if JsonApiRequestPlugin.content_expected():
596                validate_request()
597            return callback(*a, **kw)
598
599        return wrap
600
601
602class JsonApiResponsePlugin(object):
603    ''' Emits normal responses in the OpenBMC json api format. '''
604    name = 'json_api_response'
605    api = 2
606
607    def apply(self, callback, route):
608        def wrap(*a, **kw):
609            resp = {'data': callback(*a, **kw)}
610            resp['status'] = 'ok'
611            resp['message'] = response.status_line
612            return resp
613        return wrap
614
615
616class JsonApiErrorsPlugin(object):
617    ''' Emits error responses in the OpenBMC json api format. '''
618    name = 'json_api_errors'
619    api = 2
620
621    def __init__(self, **kw):
622        self.app = None
623        self.function_type = None
624        self.original = None
625        self.json_opts = {
626            x: y for x, y in kw.iteritems()
627            if x in ['indent', 'sort_keys']}
628
629    def setup(self, app):
630        self.app = app
631        self.function_type = type(app.default_error_handler)
632        self.original = app.default_error_handler
633        self.app.default_error_handler = self.function_type(
634            self.json_errors, app, Bottle)
635
636    def apply(self, callback, route):
637        return callback
638
639    def close(self):
640        self.app.default_error_handler = self.function_type(
641            self.original, self.app, Bottle)
642
643    def json_errors(self, res, error):
644        response_object = {'status': 'error', 'data': {}}
645        response_object['message'] = error.status_line
646        response_object['data']['description'] = str(error.body)
647        if error.status_code == 500:
648            response_object['data']['exception'] = repr(error.exception)
649            response_object['data']['traceback'] = error.traceback.splitlines()
650
651        json_response = json.dumps(response_object, **self.json_opts)
652        response.content_type = 'application/json'
653        return json_response
654
655
656class JsonpPlugin(JsonApiErrorsPlugin):
657    ''' Json javascript wrapper. '''
658    name = 'jsonp'
659    api = 2
660
661    def __init__(self, **kw):
662        super(JsonpPlugin, self).__init__(**kw)
663
664    @staticmethod
665    def to_jsonp(json):
666        jwrapper = request.query.callback or None
667        if(jwrapper):
668            response.set_header('Content-Type', 'application/javascript')
669            json = jwrapper + '(' + json + ');'
670        return json
671
672    def apply(self, callback, route):
673        def wrap(*a, **kw):
674            return self.to_jsonp(callback(*a, **kw))
675        return wrap
676
677    def json_errors(self, res, error):
678        json = super(JsonpPlugin, self).json_errors(res, error)
679        return self.to_jsonp(json)
680
681
682class App(Bottle):
683    def __init__(self):
684        super(App, self).__init__(autojson=False)
685        self.bus = dbus.SystemBus()
686        self.mapper = obmc.mapper.Mapper(self.bus)
687
688        self.install_hooks()
689        self.install_plugins()
690        self.create_handlers()
691        self.install_handlers()
692
693    def install_plugins(self):
694        # install json api plugins
695        json_kw = {'indent': 2, 'sort_keys': True}
696        self.install(AuthorizationPlugin())
697        self.install(JsonpPlugin(**json_kw))
698        self.install(JSONPlugin(**json_kw))
699        self.install(JsonApiResponsePlugin())
700        self.install(JsonApiRequestPlugin())
701        self.install(JsonApiRequestTypePlugin())
702
703    def install_hooks(self):
704        self.real_router_match = self.router.match
705        self.router.match = self.custom_router_match
706        self.add_hook('before_request', self.strip_extra_slashes)
707
708    def create_handlers(self):
709        # create route handlers
710        self.session_handler = SessionHandler(self, self.bus)
711        self.directory_handler = DirectoryHandler(self, self.bus)
712        self.list_names_handler = ListNamesHandler(self, self.bus)
713        self.list_handler = ListHandler(self, self.bus)
714        self.method_handler = MethodHandler(self, self.bus)
715        self.property_handler = PropertyHandler(self, self.bus)
716        self.schema_handler = SchemaHandler(self, self.bus)
717        self.instance_handler = InstanceHandler(self, self.bus)
718
719    def install_handlers(self):
720        self.session_handler.install()
721        self.directory_handler.install()
722        self.list_names_handler.install()
723        self.list_handler.install()
724        self.method_handler.install()
725        self.property_handler.install()
726        self.schema_handler.install()
727        # this has to come last, since it matches everything
728        self.instance_handler.install()
729
730    def custom_router_match(self, environ):
731        ''' The built-in Bottle algorithm for figuring out if a 404 or 405 is
732            needed doesn't work for us since the instance rules match
733            everything. This monkey-patch lets the route handler figure
734            out which response is needed.  This could be accomplished
735            with a hook but that would require calling the router match
736            function twice.
737        '''
738        route, args = self.real_router_match(environ)
739        if isinstance(route.callback, RouteHandler):
740            route.callback._setup(**args)
741
742        return route, args
743
744    @staticmethod
745    def strip_extra_slashes():
746        path = request.environ['PATH_INFO']
747        trailing = ("", "/")[path[-1] == '/']
748        parts = filter(bool, path.split('/'))
749        request.environ['PATH_INFO'] = '/' + '/'.join(parts) + trailing
750