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