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