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 23from bottle import static_file 24import obmc.utils.misc 25from obmc.dbuslib.introspection import IntrospectionNodeParser 26import obmc.mapper 27import spwd 28import grp 29import crypt 30import tempfile 31import re 32import mimetypes 33have_wsock = True 34try: 35 from geventwebsocket import WebSocketError 36except ImportError: 37 have_wsock = False 38if have_wsock: 39 from dbus.mainloop.glib import DBusGMainLoop 40 DBusGMainLoop(set_as_default=True) 41 import gobject 42 import gevent 43 44DBUS_UNKNOWN_INTERFACE = 'org.freedesktop.DBus.Error.UnknownInterface' 45DBUS_UNKNOWN_METHOD = 'org.freedesktop.DBus.Error.UnknownMethod' 46DBUS_INVALID_ARGS = 'org.freedesktop.DBus.Error.InvalidArgs' 47DBUS_TYPE_ERROR = 'org.freedesktop.DBus.Python.TypeError' 48DELETE_IFACE = 'xyz.openbmc_project.Object.Delete' 49 50_4034_msg = "The specified %s cannot be %s: '%s'" 51 52www_base_path = '/usr/share/www/' 53 54 55def valid_user(session, *a, **kw): 56 ''' Authorization plugin callback that checks 57 that the user is logged in. ''' 58 if session is None: 59 abort(401, 'Login required') 60 61 62def get_type_signature_by_introspection(bus, service, object_path, 63 property_name): 64 obj = bus.get_object(service, object_path) 65 iface = dbus.Interface(obj, 'org.freedesktop.DBus.Introspectable') 66 xml_string = iface.Introspect() 67 for child in ElementTree.fromstring(xml_string): 68 # Iterate over each interfaces's properties to find 69 # matching property_name, and return its signature string 70 if child.tag == 'interface': 71 for i in child.iter(): 72 if ('name' in i.attrib) and \ 73 (i.attrib['name'] == property_name): 74 type_signature = i.attrib['type'] 75 return type_signature 76 77 78def get_method_signature(bus, service, object_path, interface, method): 79 obj = bus.get_object(service, object_path) 80 iface = dbus.Interface(obj, 'org.freedesktop.DBus.Introspectable') 81 xml_string = iface.Introspect() 82 arglist = [] 83 84 root = ElementTree.fromstring(xml_string) 85 for dbus_intf in root.findall('interface'): 86 if (dbus_intf.get('name') == interface): 87 for dbus_method in dbus_intf.findall('method'): 88 if(dbus_method.get('name') == method): 89 for arg in dbus_method.findall('arg'): 90 arglist.append(arg.get('type')) 91 return arglist 92 93 94def split_struct_signature(signature): 95 struct_regex = r'(b|y|n|i|x|q|u|t|d|s|a\(.+?\)|\(.+?\))|a\{.+?\}+?' 96 struct_matches = re.findall(struct_regex, signature) 97 return struct_matches 98 99 100def convert_type(signature, value): 101 # Basic Types 102 converted_value = None 103 converted_container = None 104 basic_types = {'b': bool, 'y': dbus.Byte, 'n': dbus.Int16, 'i': int, 105 'x': long, 'q': dbus.UInt16, 'u': dbus.UInt32, 106 't': dbus.UInt64, 'd': float, 's': str} 107 array_matches = re.match(r'a\((\S+)\)', signature) 108 struct_matches = re.match(r'\((\S+)\)', signature) 109 dictionary_matches = re.match(r'a{(\S+)}', signature) 110 if signature in basic_types: 111 converted_value = basic_types[signature](value) 112 return converted_value 113 # Array 114 if array_matches: 115 element_type = array_matches.group(1) 116 converted_container = list() 117 # Test if value is a list 118 # to avoid iterating over each character in a string. 119 # Iterate over each item and convert type 120 if isinstance(value, list): 121 for i in value: 122 converted_element = convert_type(element_type, i) 123 converted_container.append(converted_element) 124 # Convert non-sequence to expected type, and append to list 125 else: 126 converted_element = convert_type(element_type, value) 127 converted_container.append(converted_element) 128 return converted_container 129 # Struct 130 if struct_matches: 131 element_types = struct_matches.group(1) 132 split_element_types = split_struct_signature(element_types) 133 converted_container = list() 134 # Test if value is a list 135 if isinstance(value, list): 136 for index, val in enumerate(value): 137 converted_element = convert_type(split_element_types[index], 138 value[index]) 139 converted_container.append(converted_element) 140 else: 141 converted_element = convert_type(element_types, value) 142 converted_container.append(converted_element) 143 return tuple(converted_container) 144 # Dictionary 145 if dictionary_matches: 146 element_types = dictionary_matches.group(1) 147 split_element_types = split_struct_signature(element_types) 148 converted_container = dict() 149 # Convert each element of dict 150 for key, val in value.iteritems(): 151 converted_key = convert_type(split_element_types[0], key) 152 converted_val = convert_type(split_element_types[1], val) 153 converted_container[converted_key] = converted_val 154 return converted_container 155 156 157class UserInGroup: 158 ''' Authorization plugin callback that checks that the user is logged in 159 and a member of a group. ''' 160 def __init__(self, group): 161 self.group = group 162 163 def __call__(self, session, *a, **kw): 164 valid_user(session, *a, **kw) 165 res = False 166 167 try: 168 res = session['user'] in grp.getgrnam(self.group)[3] 169 except KeyError: 170 pass 171 172 if not res: 173 abort(403, 'Insufficient access') 174 175 176class RouteHandler(object): 177 _require_auth = obmc.utils.misc.makelist(valid_user) 178 _enable_cors = True 179 180 def __init__(self, app, bus, verbs, rules, content_type=''): 181 self.app = app 182 self.bus = bus 183 self.mapper = obmc.mapper.Mapper(bus) 184 self._verbs = obmc.utils.misc.makelist(verbs) 185 self._rules = rules 186 self._content_type = content_type 187 188 if 'GET' in self._verbs: 189 self._verbs = list(set(self._verbs + ['HEAD'])) 190 if 'OPTIONS' not in self._verbs: 191 self._verbs.append('OPTIONS') 192 193 def _setup(self, **kw): 194 request.route_data = {} 195 196 if request.method in self._verbs: 197 if request.method != 'OPTIONS': 198 return self.setup(**kw) 199 200 # Javascript implementations will not send credentials 201 # with an OPTIONS request. Don't help malicious clients 202 # by checking the path here and returning a 404 if the 203 # path doesn't exist. 204 return None 205 206 # Return 405 207 raise HTTPError( 208 405, "Method not allowed.", Allow=','.join(self._verbs)) 209 210 def __call__(self, **kw): 211 return getattr(self, 'do_' + request.method.lower())(**kw) 212 213 def do_head(self, **kw): 214 return self.do_get(**kw) 215 216 def do_options(self, **kw): 217 for v in self._verbs: 218 response.set_header( 219 'Allow', 220 ','.join(self._verbs)) 221 return None 222 223 def install(self): 224 self.app.route( 225 self._rules, callback=self, 226 method=['OPTIONS', 'GET', 'PUT', 'PATCH', 'POST', 'DELETE']) 227 228 @staticmethod 229 def try_mapper_call(f, callback=None, **kw): 230 try: 231 return f(**kw) 232 except dbus.exceptions.DBusException, e: 233 if e.get_dbus_name() == \ 234 'org.freedesktop.DBus.Error.ObjectPathInUse': 235 abort(503, str(e)) 236 if e.get_dbus_name() != obmc.mapper.MAPPER_NOT_FOUND: 237 raise 238 if callback is None: 239 def callback(e, **kw): 240 abort(404, str(e)) 241 242 callback(e, **kw) 243 244 @staticmethod 245 def try_properties_interface(f, *a): 246 try: 247 return f(*a) 248 except dbus.exceptions.DBusException, e: 249 if DBUS_UNKNOWN_INTERFACE in e.get_dbus_name(): 250 # interface doesn't have any properties 251 return None 252 if DBUS_UNKNOWN_METHOD == e.get_dbus_name(): 253 # properties interface not implemented at all 254 return None 255 raise 256 257 258class DirectoryHandler(RouteHandler): 259 verbs = 'GET' 260 rules = '<path:path>/' 261 262 def __init__(self, app, bus): 263 super(DirectoryHandler, self).__init__( 264 app, bus, self.verbs, self.rules) 265 266 def find(self, path='/'): 267 return self.try_mapper_call( 268 self.mapper.get_subtree_paths, path=path, depth=1) 269 270 def setup(self, path='/'): 271 request.route_data['map'] = self.find(path) 272 273 def do_get(self, path='/'): 274 return request.route_data['map'] 275 276 277class ListNamesHandler(RouteHandler): 278 verbs = 'GET' 279 rules = ['/list', '<path:path>/list'] 280 281 def __init__(self, app, bus): 282 super(ListNamesHandler, self).__init__( 283 app, bus, self.verbs, self.rules) 284 285 def find(self, path='/'): 286 return self.try_mapper_call( 287 self.mapper.get_subtree, path=path).keys() 288 289 def setup(self, path='/'): 290 request.route_data['map'] = self.find(path) 291 292 def do_get(self, path='/'): 293 return request.route_data['map'] 294 295 296class ListHandler(RouteHandler): 297 verbs = 'GET' 298 rules = ['/enumerate', '<path:path>/enumerate'] 299 300 def __init__(self, app, bus): 301 super(ListHandler, self).__init__( 302 app, bus, self.verbs, self.rules) 303 304 def find(self, path='/'): 305 return self.try_mapper_call( 306 self.mapper.get_subtree, path=path) 307 308 def setup(self, path='/'): 309 request.route_data['map'] = self.find(path) 310 311 def do_get(self, path='/'): 312 return {x: y for x, y in self.mapper.enumerate_subtree( 313 path, 314 mapper_data=request.route_data['map']).dataitems()} 315 316 317class MethodHandler(RouteHandler): 318 verbs = 'POST' 319 rules = '<path:path>/action/<method>' 320 request_type = list 321 content_type = 'application/json' 322 323 def __init__(self, app, bus): 324 super(MethodHandler, self).__init__( 325 app, bus, self.verbs, self.rules, self.content_type) 326 self.service = '' 327 self.interface = '' 328 329 def find(self, path, method): 330 method_list = [] 331 busses = self.try_mapper_call( 332 self.mapper.get_object, path=path) 333 for items in busses.iteritems(): 334 m = self.find_method_on_bus(path, method, *items) 335 if m: 336 method_list.append(m) 337 if method_list: 338 return method_list 339 340 abort(404, _4034_msg % ('method', 'found', method)) 341 342 def setup(self, path, method): 343 request.route_data['map'] = self.find(path, method) 344 345 def do_post(self, path, method, retry=True): 346 try: 347 args = [] 348 if request.parameter_list: 349 args = request.parameter_list 350 # To see if the return type is capable of being merged 351 if len(request.route_data['map']) > 1: 352 results = None 353 for item in request.route_data['map']: 354 tmp = item(*args) 355 if not results: 356 if tmp is not None: 357 results = type(tmp)() 358 if isinstance(results, dict): 359 results = results.update(tmp) 360 elif isinstance(results, list): 361 results = results + tmp 362 elif isinstance(results, type(None)): 363 results = None 364 else: 365 abort(501, 'Don\'t know how to merge method call ' 366 'results of {}'.format(type(tmp))) 367 return results 368 # There is only one method 369 return request.route_data['map'][0](*args) 370 371 except dbus.exceptions.DBusException, e: 372 paramlist = [] 373 if e.get_dbus_name() == DBUS_INVALID_ARGS and retry: 374 375 signature_list = get_method_signature(self.bus, self.service, 376 path, self.interface, 377 method) 378 if not signature_list: 379 abort(400, "Failed to get method signature: %s" % str(e)) 380 if len(signature_list) != len(request.parameter_list): 381 abort(400, "Invalid number of args") 382 converted_value = None 383 try: 384 for index, expected_type in enumerate(signature_list): 385 value = request.parameter_list[index] 386 converted_value = convert_type(expected_type, value) 387 paramlist.append(converted_value) 388 request.parameter_list = paramlist 389 self.do_post(path, method, False) 390 return 391 except Exception as ex: 392 abort(400, "Bad Request/Invalid Args given") 393 abort(400, str(e)) 394 395 if e.get_dbus_name() == DBUS_TYPE_ERROR: 396 abort(400, str(e)) 397 raise 398 399 @staticmethod 400 def find_method_in_interface(method, obj, interface, methods): 401 if methods is None: 402 return None 403 404 method = obmc.utils.misc.find_case_insensitive(method, methods.keys()) 405 if method is not None: 406 iface = dbus.Interface(obj, interface) 407 return iface.get_dbus_method(method) 408 409 def find_method_on_bus(self, path, method, bus, interfaces): 410 obj = self.bus.get_object(bus, path, introspect=False) 411 iface = dbus.Interface(obj, dbus.INTROSPECTABLE_IFACE) 412 data = iface.Introspect() 413 parser = IntrospectionNodeParser( 414 ElementTree.fromstring(data), 415 intf_match=obmc.utils.misc.ListMatch(interfaces)) 416 for x, y in parser.get_interfaces().iteritems(): 417 m = self.find_method_in_interface( 418 method, obj, x, y.get('method')) 419 if m: 420 self.service = bus 421 self.interface = x 422 return m 423 424 425class PropertyHandler(RouteHandler): 426 verbs = ['PUT', 'GET'] 427 rules = '<path:path>/attr/<prop>' 428 content_type = 'application/json' 429 430 def __init__(self, app, bus): 431 super(PropertyHandler, self).__init__( 432 app, bus, self.verbs, self.rules, self.content_type) 433 434 def find(self, path, prop): 435 self.app.instance_handler.setup(path) 436 obj = self.app.instance_handler.do_get(path) 437 real_name = obmc.utils.misc.find_case_insensitive( 438 prop, obj.keys()) 439 440 if not real_name: 441 if request.method == 'PUT': 442 abort(403, _4034_msg % ('property', 'created', prop)) 443 else: 444 abort(404, _4034_msg % ('property', 'found', prop)) 445 return real_name, {path: obj} 446 447 def setup(self, path, prop): 448 name, obj = self.find(path, prop) 449 request.route_data['obj'] = obj 450 request.route_data['name'] = name 451 452 def do_get(self, path, prop): 453 name = request.route_data['name'] 454 return request.route_data['obj'][path][name] 455 456 def do_put(self, path, prop, value=None, retry=True): 457 if value is None: 458 value = request.parameter_list 459 460 prop, iface, properties_iface = self.get_host_interface( 461 path, prop, request.route_data['map'][path]) 462 try: 463 properties_iface.Set(iface, prop, value) 464 except ValueError, e: 465 abort(400, str(e)) 466 except dbus.exceptions.DBusException, e: 467 if e.get_dbus_name() == DBUS_INVALID_ARGS and retry: 468 bus_name = properties_iface.bus_name 469 expected_type = get_type_signature_by_introspection(self.bus, 470 bus_name, 471 path, 472 prop) 473 if not expected_type: 474 abort(403, "Failed to get expected type: %s" % str(e)) 475 converted_value = None 476 try: 477 converted_value = convert_type(expected_type, value) 478 self.do_put(path, prop, converted_value, False) 479 return 480 except Exception as ex: 481 abort(403, "Failed to convert %s to type %s" % 482 (value, expected_type)) 483 abort(403, str(e)) 484 raise 485 486 def get_host_interface(self, path, prop, bus_info): 487 for bus, interfaces in bus_info.iteritems(): 488 obj = self.bus.get_object(bus, path, introspect=True) 489 properties_iface = dbus.Interface( 490 obj, dbus_interface=dbus.PROPERTIES_IFACE) 491 492 info = self.get_host_interface_on_bus( 493 path, prop, properties_iface, bus, interfaces) 494 if info is not None: 495 prop, iface = info 496 return prop, iface, properties_iface 497 498 def get_host_interface_on_bus(self, path, prop, iface, bus, interfaces): 499 for i in interfaces: 500 properties = self.try_properties_interface(iface.GetAll, i) 501 if not properties: 502 continue 503 match = obmc.utils.misc.find_case_insensitive( 504 prop, properties.keys()) 505 if match is None: 506 continue 507 prop = match 508 return prop, i 509 510 511class SchemaHandler(RouteHandler): 512 verbs = ['GET'] 513 rules = '<path:path>/schema' 514 515 def __init__(self, app, bus): 516 super(SchemaHandler, self).__init__( 517 app, bus, self.verbs, self.rules) 518 519 def find(self, path): 520 return self.try_mapper_call( 521 self.mapper.get_object, 522 path=path) 523 524 def setup(self, path): 525 request.route_data['map'] = self.find(path) 526 527 def do_get(self, path): 528 schema = {} 529 for x in request.route_data['map'].iterkeys(): 530 obj = self.bus.get_object(x, path, introspect=False) 531 iface = dbus.Interface(obj, dbus.INTROSPECTABLE_IFACE) 532 data = iface.Introspect() 533 parser = IntrospectionNodeParser( 534 ElementTree.fromstring(data)) 535 for x, y in parser.get_interfaces().iteritems(): 536 schema[x] = y 537 538 return schema 539 540 541class InstanceHandler(RouteHandler): 542 verbs = ['GET', 'PUT', 'DELETE'] 543 rules = '<path:path>' 544 request_type = dict 545 546 def __init__(self, app, bus): 547 super(InstanceHandler, self).__init__( 548 app, bus, self.verbs, self.rules) 549 550 def find(self, path, callback=None): 551 return {path: self.try_mapper_call( 552 self.mapper.get_object, 553 callback, 554 path=path)} 555 556 def setup(self, path): 557 callback = None 558 if request.method == 'PUT': 559 def callback(e, **kw): 560 abort(403, _4034_msg % ('resource', 'created', path)) 561 562 if request.route_data.get('map') is None: 563 request.route_data['map'] = self.find(path, callback) 564 565 def do_get(self, path): 566 return self.mapper.enumerate_object( 567 path, 568 mapper_data=request.route_data['map']) 569 570 def do_put(self, path): 571 # make sure all properties exist in the request 572 obj = set(self.do_get(path).keys()) 573 req = set(request.parameter_list.keys()) 574 575 diff = list(obj.difference(req)) 576 if diff: 577 abort(403, _4034_msg % ( 578 'resource', 'removed', '%s/attr/%s' % (path, diff[0]))) 579 580 diff = list(req.difference(obj)) 581 if diff: 582 abort(403, _4034_msg % ( 583 'resource', 'created', '%s/attr/%s' % (path, diff[0]))) 584 585 for p, v in request.parameter_list.iteritems(): 586 self.app.property_handler.do_put( 587 path, p, v) 588 589 def do_delete(self, path): 590 for bus_info in request.route_data['map'][path].iteritems(): 591 if self.bus_missing_delete(path, *bus_info): 592 abort(403, _4034_msg % ('resource', 'removed', path)) 593 594 for bus in request.route_data['map'][path].iterkeys(): 595 self.delete_on_bus(path, bus) 596 597 def bus_missing_delete(self, path, bus, interfaces): 598 return DELETE_IFACE not in interfaces 599 600 def delete_on_bus(self, path, bus): 601 obj = self.bus.get_object(bus, path, introspect=False) 602 delete_iface = dbus.Interface( 603 obj, dbus_interface=DELETE_IFACE) 604 delete_iface.Delete() 605 606 607class SessionHandler(MethodHandler): 608 ''' Handles the /login and /logout routes, manages 609 server side session store and session cookies. ''' 610 611 rules = ['/login', '/logout'] 612 login_str = "User '%s' logged %s" 613 bad_passwd_str = "Invalid username or password" 614 no_user_str = "No user logged in" 615 bad_json_str = "Expecting request format { 'data': " \ 616 "[<username>, <password>] }, got '%s'" 617 _require_auth = None 618 MAX_SESSIONS = 16 619 620 def __init__(self, app, bus): 621 super(SessionHandler, self).__init__( 622 app, bus) 623 self.hmac_key = os.urandom(128) 624 self.session_store = [] 625 626 @staticmethod 627 def authenticate(username, clear): 628 try: 629 encoded = spwd.getspnam(username)[1] 630 return encoded == crypt.crypt(clear, encoded) 631 except KeyError: 632 return False 633 634 def invalidate_session(self, session): 635 try: 636 self.session_store.remove(session) 637 except ValueError: 638 pass 639 640 def new_session(self): 641 sid = os.urandom(32) 642 if self.MAX_SESSIONS <= len(self.session_store): 643 self.session_store.pop() 644 self.session_store.insert(0, {'sid': sid}) 645 646 return self.session_store[0] 647 648 def get_session(self, sid): 649 sids = [x['sid'] for x in self.session_store] 650 try: 651 return self.session_store[sids.index(sid)] 652 except ValueError: 653 return None 654 655 def get_session_from_cookie(self): 656 return self.get_session( 657 request.get_cookie( 658 'sid', secret=self.hmac_key)) 659 660 def do_post(self, **kw): 661 if request.path == '/login': 662 return self.do_login(**kw) 663 else: 664 return self.do_logout(**kw) 665 666 def do_logout(self, **kw): 667 session = self.get_session_from_cookie() 668 if session is not None: 669 user = session['user'] 670 self.invalidate_session(session) 671 response.delete_cookie('sid') 672 return self.login_str % (user, 'out') 673 674 return self.no_user_str 675 676 def do_login(self, **kw): 677 session = self.get_session_from_cookie() 678 if session is not None: 679 return self.login_str % (session['user'], 'in') 680 681 if len(request.parameter_list) != 2: 682 abort(400, self.bad_json_str % (request.json)) 683 684 if not self.authenticate(*request.parameter_list): 685 abort(401, self.bad_passwd_str) 686 687 user = request.parameter_list[0] 688 session = self.new_session() 689 session['user'] = user 690 response.set_cookie( 691 'sid', session['sid'], secret=self.hmac_key, 692 secure=True, 693 httponly=True) 694 return self.login_str % (user, 'in') 695 696 def find(self, **kw): 697 pass 698 699 def setup(self, **kw): 700 pass 701 702 703class ImageUploadUtils: 704 ''' Provides common utils for image upload. ''' 705 706 file_loc = '/tmp/images' 707 file_prefix = 'img' 708 file_suffix = '' 709 710 @classmethod 711 def do_upload(cls, filename=''): 712 if not os.path.exists(cls.file_loc): 713 abort(500, "Error Directory not found") 714 if not filename: 715 handle, filename = tempfile.mkstemp(cls.file_suffix, 716 cls.file_prefix, cls.file_loc) 717 else: 718 filename = os.path.join(cls.file_loc, filename) 719 handle = os.open(filename, os.O_WRONLY | os.O_CREAT) 720 try: 721 file_contents = request.body.read() 722 request.body.close() 723 os.write(handle, file_contents) 724 except (IOError, ValueError), e: 725 abort(400, str(e)) 726 except: 727 abort(400, "Unexpected Error") 728 finally: 729 os.close(handle) 730 731 732class ImagePostHandler(RouteHandler): 733 ''' Handles the /upload/image route. ''' 734 735 verbs = ['POST'] 736 rules = ['/upload/image'] 737 content_type = 'application/octet-stream' 738 739 def __init__(self, app, bus): 740 super(ImagePostHandler, self).__init__( 741 app, bus, self.verbs, self.rules, self.content_type) 742 743 def do_post(self, filename=''): 744 ImageUploadUtils.do_upload() 745 746 def find(self, **kw): 747 pass 748 749 def setup(self, **kw): 750 pass 751 752 753class EventNotifier: 754 keyNames = {} 755 keyNames['event'] = 'event' 756 keyNames['path'] = 'path' 757 keyNames['intfMap'] = 'interfaces' 758 keyNames['propMap'] = 'properties' 759 keyNames['intf'] = 'interface' 760 761 def __init__(self, wsock, filters): 762 self.wsock = wsock 763 self.paths = filters.get("paths", []) 764 self.interfaces = filters.get("interfaces", []) 765 if not self.paths: 766 self.paths.append(None) 767 bus = dbus.SystemBus() 768 # Add a signal receiver for every path the client is interested in 769 for path in self.paths: 770 bus.add_signal_receiver( 771 self.interfaces_added_handler, 772 dbus_interface=dbus.BUS_DAEMON_IFACE + '.ObjectManager', 773 signal_name='InterfacesAdded', 774 path=path) 775 bus.add_signal_receiver( 776 self.properties_changed_handler, 777 dbus_interface=dbus.PROPERTIES_IFACE, 778 signal_name='PropertiesChanged', 779 path=path, 780 path_keyword='path') 781 loop = gobject.MainLoop() 782 # gobject's mainloop.run() will block the entire process, so the gevent 783 # scheduler and hence greenlets won't execute. The while-loop below 784 # works around this limitation by using gevent's sleep, instead of 785 # calling loop.run() 786 gcontext = loop.get_context() 787 while loop is not None: 788 try: 789 if gcontext.pending(): 790 gcontext.iteration() 791 else: 792 # gevent.sleep puts only the current greenlet to sleep, 793 # not the entire process. 794 gevent.sleep(5) 795 except WebSocketError: 796 break 797 798 def interfaces_added_handler(self, path, iprops, **kw): 799 ''' If the client is interested in these changes, respond to the 800 client. This handles d-bus interface additions.''' 801 if (not self.interfaces) or \ 802 (not set(iprops).isdisjoint(self.interfaces)): 803 response = {} 804 response[self.keyNames['event']] = "InterfacesAdded" 805 response[self.keyNames['path']] = path 806 response[self.keyNames['intfMap']] = iprops 807 try: 808 self.wsock.send(json.dumps(response)) 809 except WebSocketError: 810 return 811 812 def properties_changed_handler(self, interface, new, old, **kw): 813 ''' If the client is interested in these changes, respond to the 814 client. This handles d-bus property changes. ''' 815 if (not self.interfaces) or (interface in self.interfaces): 816 path = str(kw['path']) 817 response = {} 818 response[self.keyNames['event']] = "PropertiesChanged" 819 response[self.keyNames['path']] = path 820 response[self.keyNames['intf']] = interface 821 response[self.keyNames['propMap']] = new 822 try: 823 self.wsock.send(json.dumps(response)) 824 except WebSocketError: 825 return 826 827 828class EventHandler(RouteHandler): 829 ''' Handles the /subscribe route, for clients to be able 830 to subscribe to BMC events. ''' 831 832 verbs = ['GET'] 833 rules = ['/subscribe'] 834 835 def __init__(self, app, bus): 836 super(EventHandler, self).__init__( 837 app, bus, self.verbs, self.rules) 838 839 def find(self, **kw): 840 pass 841 842 def setup(self, **kw): 843 pass 844 845 def do_get(self): 846 wsock = request.environ.get('wsgi.websocket') 847 if not wsock: 848 abort(400, 'Expected WebSocket request.') 849 filters = wsock.receive() 850 filters = json.loads(filters) 851 notifier = EventNotifier(wsock, filters) 852 853 854class ImagePutHandler(RouteHandler): 855 ''' Handles the /upload/image/<filename> route. ''' 856 857 verbs = ['PUT'] 858 rules = ['/upload/image/<filename>'] 859 content_type = 'application/octet-stream' 860 861 def __init__(self, app, bus): 862 super(ImagePutHandler, self).__init__( 863 app, bus, self.verbs, self.rules, self.content_type) 864 865 def do_put(self, filename=''): 866 ImageUploadUtils.do_upload(filename) 867 868 def find(self, **kw): 869 pass 870 871 def setup(self, **kw): 872 pass 873 874 875class DownloadDumpHandler(RouteHandler): 876 ''' Handles the /download/dump route. ''' 877 878 verbs = 'GET' 879 rules = ['/download/dump/<dumpid>'] 880 content_type = 'application/octet-stream' 881 dump_loc = '/var/lib/phosphor-debug-collector/dumps' 882 suppress_json_resp = True 883 884 def __init__(self, app, bus): 885 super(DownloadDumpHandler, self).__init__( 886 app, bus, self.verbs, self.rules, self.content_type) 887 888 def do_get(self, dumpid): 889 return self.do_download(dumpid) 890 891 def find(self, **kw): 892 pass 893 894 def setup(self, **kw): 895 pass 896 897 def do_download(self, dumpid): 898 dump_loc = os.path.join(self.dump_loc, dumpid) 899 if not os.path.exists(dump_loc): 900 abort(404, "Path not found") 901 902 files = os.listdir(dump_loc) 903 num_files = len(files) 904 if num_files == 0: 905 abort(404, "Dump not found") 906 907 return static_file(os.path.basename(files[0]), root=dump_loc, 908 download=True, mimetype=self.content_type) 909 910 911class WebHandler(RouteHandler): 912 ''' Handles the routes for the web UI files. ''' 913 914 verbs = 'GET' 915 916 # Match only what we know are web files, so everything else 917 # can get routed to the REST handlers. 918 rules = ['//', '/<filename:re:.+\.js>', '/<filename:re:.+\.svg>', 919 '/<filename:re:.+\.css>', '/<filename:re:.+\.ttf>', 920 '/<filename:re:.+\.eot>', '/<filename:re:.+\.woff>', 921 '/<filename:re:.+\.woff2>', '/<filename:re:.+\.map>', 922 '/<filename:re:.+\.png>', '/<filename:re:.+\.html>', 923 '/<filename:re:.+\.ico>'] 924 925 # The mimetypes module knows about most types, but not these 926 content_types = { 927 '.eot': 'application/vnd.ms-fontobject', 928 '.woff': 'application/x-font-woff', 929 '.woff2': 'application/x-font-woff2', 930 '.ttf': 'application/x-font-ttf', 931 '.map': 'application/json' 932 } 933 934 _require_auth = None 935 suppress_json_resp = True 936 937 def __init__(self, app, bus): 938 super(WebHandler, self).__init__( 939 app, bus, self.verbs, self.rules) 940 941 def get_type(self, filename): 942 ''' Returns the content type and encoding for a file ''' 943 944 content_type, encoding = mimetypes.guess_type(filename) 945 946 # Try our own list if mimetypes didn't recognize it 947 if content_type is None: 948 if filename[-3:] == '.gz': 949 filename = filename[:-3] 950 extension = filename[filename.rfind('.'):] 951 content_type = self.content_types.get(extension, None) 952 953 return content_type, encoding 954 955 def do_get(self, filename='index.html'): 956 957 # If a gzipped version exists, use that instead. 958 # Possible future enhancement: if the client doesn't 959 # accept compressed files, unzip it ourselves before sending. 960 if not os.path.exists(os.path.join(www_base_path, filename)): 961 filename = filename + '.gz' 962 963 # Though bottle should protect us, ensure path is valid 964 realpath = os.path.realpath(filename) 965 if realpath[0] == '/': 966 realpath = realpath[1:] 967 if not os.path.exists(os.path.join(www_base_path, realpath)): 968 abort(404, "Path not found") 969 970 mimetype, encoding = self.get_type(filename) 971 972 # Couldn't find the type - let static_file() deal with it, 973 # though this should never happen. 974 if mimetype is None: 975 print("Can't figure out content-type for %s" % filename) 976 mimetype = 'auto' 977 978 # This call will set several header fields for us, 979 # including the charset if the type is text. 980 response = static_file(filename, www_base_path, mimetype) 981 982 # static_file() will only set the encoding if the 983 # mimetype was auto, so set it here. 984 if encoding is not None: 985 response.set_header('Content-Encoding', encoding) 986 987 return response 988 989 def find(self, **kw): 990 pass 991 992 def setup(self, **kw): 993 pass 994 995 996class AuthorizationPlugin(object): 997 ''' Invokes an optional list of authorization callbacks. ''' 998 999 name = 'authorization' 1000 api = 2 1001 1002 class Compose: 1003 def __init__(self, validators, callback, session_mgr): 1004 self.validators = validators 1005 self.callback = callback 1006 self.session_mgr = session_mgr 1007 1008 def __call__(self, *a, **kw): 1009 sid = request.get_cookie('sid', secret=self.session_mgr.hmac_key) 1010 session = self.session_mgr.get_session(sid) 1011 if request.method != 'OPTIONS': 1012 for x in self.validators: 1013 x(session, *a, **kw) 1014 1015 return self.callback(*a, **kw) 1016 1017 def apply(self, callback, route): 1018 undecorated = route.get_undecorated_callback() 1019 if not isinstance(undecorated, RouteHandler): 1020 return callback 1021 1022 auth_types = getattr( 1023 undecorated, '_require_auth', None) 1024 if not auth_types: 1025 return callback 1026 1027 return self.Compose( 1028 auth_types, callback, undecorated.app.session_handler) 1029 1030 1031class CorsPlugin(object): 1032 ''' Add CORS headers. ''' 1033 1034 name = 'cors' 1035 api = 2 1036 1037 @staticmethod 1038 def process_origin(): 1039 origin = request.headers.get('Origin') 1040 if origin: 1041 response.add_header('Access-Control-Allow-Origin', origin) 1042 response.add_header( 1043 'Access-Control-Allow-Credentials', 'true') 1044 1045 @staticmethod 1046 def process_method_and_headers(verbs): 1047 method = request.headers.get('Access-Control-Request-Method') 1048 headers = request.headers.get('Access-Control-Request-Headers') 1049 if headers: 1050 headers = [x.lower() for x in headers.split(',')] 1051 1052 if method in verbs \ 1053 and headers == ['content-type']: 1054 response.add_header('Access-Control-Allow-Methods', method) 1055 response.add_header( 1056 'Access-Control-Allow-Headers', 'Content-Type') 1057 response.add_header('X-Frame-Options', 'deny') 1058 response.add_header('X-Content-Type-Options', 'nosniff') 1059 response.add_header('X-XSS-Protection', '1; mode=block') 1060 response.add_header( 1061 'Content-Security-Policy', "default-src 'self'") 1062 response.add_header( 1063 'Strict-Transport-Security', 1064 'max-age=31536000; includeSubDomains; preload') 1065 1066 def __init__(self, app): 1067 app.install_error_callback(self.error_callback) 1068 1069 def apply(self, callback, route): 1070 undecorated = route.get_undecorated_callback() 1071 if not isinstance(undecorated, RouteHandler): 1072 return callback 1073 1074 if not getattr(undecorated, '_enable_cors', None): 1075 return callback 1076 1077 def wrap(*a, **kw): 1078 self.process_origin() 1079 self.process_method_and_headers(undecorated._verbs) 1080 return callback(*a, **kw) 1081 1082 return wrap 1083 1084 def error_callback(self, **kw): 1085 self.process_origin() 1086 1087 1088class JsonApiRequestPlugin(object): 1089 ''' Ensures request content satisfies the OpenBMC json api format. ''' 1090 name = 'json_api_request' 1091 api = 2 1092 1093 error_str = "Expecting request format { 'data': <value> }, got '%s'" 1094 type_error_str = "Unsupported Content-Type: '%s'" 1095 json_type = "application/json" 1096 request_methods = ['PUT', 'POST', 'PATCH'] 1097 1098 @staticmethod 1099 def content_expected(): 1100 return request.method in JsonApiRequestPlugin.request_methods 1101 1102 def validate_request(self): 1103 if request.content_length > 0 and \ 1104 request.content_type != self.json_type: 1105 abort(415, self.type_error_str % request.content_type) 1106 1107 try: 1108 request.parameter_list = request.json.get('data') 1109 except ValueError, e: 1110 abort(400, str(e)) 1111 except (AttributeError, KeyError, TypeError): 1112 abort(400, self.error_str % request.json) 1113 1114 def apply(self, callback, route): 1115 content_type = getattr( 1116 route.get_undecorated_callback(), '_content_type', None) 1117 if self.json_type != content_type: 1118 return callback 1119 1120 verbs = getattr( 1121 route.get_undecorated_callback(), '_verbs', None) 1122 if verbs is None: 1123 return callback 1124 1125 if not set(self.request_methods).intersection(verbs): 1126 return callback 1127 1128 def wrap(*a, **kw): 1129 if self.content_expected(): 1130 self.validate_request() 1131 return callback(*a, **kw) 1132 1133 return wrap 1134 1135 1136class JsonApiRequestTypePlugin(object): 1137 ''' Ensures request content type satisfies the OpenBMC json api format. ''' 1138 name = 'json_api_method_request' 1139 api = 2 1140 1141 error_str = "Expecting request format { 'data': %s }, got '%s'" 1142 json_type = "application/json" 1143 1144 def apply(self, callback, route): 1145 content_type = getattr( 1146 route.get_undecorated_callback(), '_content_type', None) 1147 if self.json_type != content_type: 1148 return callback 1149 1150 request_type = getattr( 1151 route.get_undecorated_callback(), 'request_type', None) 1152 if request_type is None: 1153 return callback 1154 1155 def validate_request(): 1156 if not isinstance(request.parameter_list, request_type): 1157 abort(400, self.error_str % (str(request_type), request.json)) 1158 1159 def wrap(*a, **kw): 1160 if JsonApiRequestPlugin.content_expected(): 1161 validate_request() 1162 return callback(*a, **kw) 1163 1164 return wrap 1165 1166 1167class JsonErrorsPlugin(JSONPlugin): 1168 ''' Extend the Bottle JSONPlugin such that it also encodes error 1169 responses. ''' 1170 1171 def __init__(self, app, **kw): 1172 super(JsonErrorsPlugin, self).__init__(**kw) 1173 self.json_opts = { 1174 x: y for x, y in kw.iteritems() 1175 if x in ['indent', 'sort_keys']} 1176 app.install_error_callback(self.error_callback) 1177 1178 def error_callback(self, response_object, response_body, **kw): 1179 response_body['body'] = json.dumps(response_object, **self.json_opts) 1180 response.content_type = 'application/json' 1181 1182 1183class JsonApiResponsePlugin(object): 1184 ''' Emits responses in the OpenBMC json api format. ''' 1185 name = 'json_api_response' 1186 api = 2 1187 1188 @staticmethod 1189 def has_body(): 1190 return request.method not in ['OPTIONS'] 1191 1192 def __init__(self, app): 1193 app.install_error_callback(self.error_callback) 1194 1195 def apply(self, callback, route): 1196 skip = getattr( 1197 route.get_undecorated_callback(), 'suppress_json_resp', None) 1198 if skip: 1199 return callback 1200 1201 def wrap(*a, **kw): 1202 data = callback(*a, **kw) 1203 if self.has_body(): 1204 resp = {'data': data} 1205 resp['status'] = 'ok' 1206 resp['message'] = response.status_line 1207 return resp 1208 return wrap 1209 1210 def error_callback(self, error, response_object, **kw): 1211 response_object['message'] = error.status_line 1212 response_object['status'] = 'error' 1213 response_object.setdefault('data', {})['description'] = str(error.body) 1214 if error.status_code == 500: 1215 response_object['data']['exception'] = repr(error.exception) 1216 response_object['data']['traceback'] = error.traceback.splitlines() 1217 1218 1219class JsonpPlugin(object): 1220 ''' Json javascript wrapper. ''' 1221 name = 'jsonp' 1222 api = 2 1223 1224 def __init__(self, app, **kw): 1225 app.install_error_callback(self.error_callback) 1226 1227 @staticmethod 1228 def to_jsonp(json): 1229 jwrapper = request.query.callback or None 1230 if(jwrapper): 1231 response.set_header('Content-Type', 'application/javascript') 1232 json = jwrapper + '(' + json + ');' 1233 return json 1234 1235 def apply(self, callback, route): 1236 def wrap(*a, **kw): 1237 return self.to_jsonp(callback(*a, **kw)) 1238 return wrap 1239 1240 def error_callback(self, response_body, **kw): 1241 response_body['body'] = self.to_jsonp(response_body['body']) 1242 1243 1244class ContentCheckerPlugin(object): 1245 ''' Ensures that a route is associated with the expected content-type 1246 header. ''' 1247 name = 'content_checker' 1248 api = 2 1249 1250 class Checker: 1251 def __init__(self, type, callback): 1252 self.expected_type = type 1253 self.callback = callback 1254 self.error_str = "Expecting content type '%s', got '%s'" 1255 1256 def __call__(self, *a, **kw): 1257 if request.method in ['PUT', 'POST', 'PATCH'] and \ 1258 self.expected_type and \ 1259 self.expected_type != request.content_type: 1260 abort(415, self.error_str % (self.expected_type, 1261 request.content_type)) 1262 1263 return self.callback(*a, **kw) 1264 1265 def apply(self, callback, route): 1266 content_type = getattr( 1267 route.get_undecorated_callback(), '_content_type', None) 1268 1269 return self.Checker(content_type, callback) 1270 1271 1272class App(Bottle): 1273 def __init__(self, **kw): 1274 super(App, self).__init__(autojson=False) 1275 1276 self.have_wsock = kw.get('have_wsock', False) 1277 1278 self.bus = dbus.SystemBus() 1279 self.mapper = obmc.mapper.Mapper(self.bus) 1280 self.error_callbacks = [] 1281 1282 self.install_hooks() 1283 self.install_plugins() 1284 self.create_handlers() 1285 self.install_handlers() 1286 1287 def install_plugins(self): 1288 # install json api plugins 1289 json_kw = {'indent': 2, 'sort_keys': True} 1290 self.install(AuthorizationPlugin()) 1291 self.install(CorsPlugin(self)) 1292 self.install(ContentCheckerPlugin()) 1293 self.install(JsonpPlugin(self, **json_kw)) 1294 self.install(JsonErrorsPlugin(self, **json_kw)) 1295 self.install(JsonApiResponsePlugin(self)) 1296 self.install(JsonApiRequestPlugin()) 1297 self.install(JsonApiRequestTypePlugin()) 1298 1299 def install_hooks(self): 1300 self.error_handler_type = type(self.default_error_handler) 1301 self.original_error_handler = self.default_error_handler 1302 self.default_error_handler = self.error_handler_type( 1303 self.custom_error_handler, self, Bottle) 1304 1305 self.real_router_match = self.router.match 1306 self.router.match = self.custom_router_match 1307 self.add_hook('before_request', self.strip_extra_slashes) 1308 1309 def create_handlers(self): 1310 # create route handlers 1311 self.session_handler = SessionHandler(self, self.bus) 1312 self.web_handler = WebHandler(self, self.bus) 1313 self.directory_handler = DirectoryHandler(self, self.bus) 1314 self.list_names_handler = ListNamesHandler(self, self.bus) 1315 self.list_handler = ListHandler(self, self.bus) 1316 self.method_handler = MethodHandler(self, self.bus) 1317 self.property_handler = PropertyHandler(self, self.bus) 1318 self.schema_handler = SchemaHandler(self, self.bus) 1319 self.image_upload_post_handler = ImagePostHandler(self, self.bus) 1320 self.image_upload_put_handler = ImagePutHandler(self, self.bus) 1321 self.download_dump_get_handler = DownloadDumpHandler(self, self.bus) 1322 if self.have_wsock: 1323 self.event_handler = EventHandler(self, self.bus) 1324 self.instance_handler = InstanceHandler(self, self.bus) 1325 1326 def install_handlers(self): 1327 self.session_handler.install() 1328 self.web_handler.install() 1329 self.directory_handler.install() 1330 self.list_names_handler.install() 1331 self.list_handler.install() 1332 self.method_handler.install() 1333 self.property_handler.install() 1334 self.schema_handler.install() 1335 self.image_upload_post_handler.install() 1336 self.image_upload_put_handler.install() 1337 self.download_dump_get_handler.install() 1338 if self.have_wsock: 1339 self.event_handler.install() 1340 # this has to come last, since it matches everything 1341 self.instance_handler.install() 1342 1343 def install_error_callback(self, callback): 1344 self.error_callbacks.insert(0, callback) 1345 1346 def custom_router_match(self, environ): 1347 ''' The built-in Bottle algorithm for figuring out if a 404 or 405 is 1348 needed doesn't work for us since the instance rules match 1349 everything. This monkey-patch lets the route handler figure 1350 out which response is needed. This could be accomplished 1351 with a hook but that would require calling the router match 1352 function twice. 1353 ''' 1354 route, args = self.real_router_match(environ) 1355 if isinstance(route.callback, RouteHandler): 1356 route.callback._setup(**args) 1357 1358 return route, args 1359 1360 def custom_error_handler(self, res, error): 1361 ''' Allow plugins to modify error responses too via this custom 1362 error handler. ''' 1363 1364 response_object = {} 1365 response_body = {} 1366 for x in self.error_callbacks: 1367 x(error=error, 1368 response_object=response_object, 1369 response_body=response_body) 1370 1371 return response_body.get('body', "") 1372 1373 @staticmethod 1374 def strip_extra_slashes(): 1375 path = request.environ['PATH_INFO'] 1376 trailing = ("", "/")[path[-1] == '/'] 1377 parts = filter(bool, path.split('/')) 1378 request.environ['PATH_INFO'] = '/' + '/'.join(parts) + trailing 1379