# Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. """Implementation of JSON RPC for communication between API and conductors. This module implementa a subset of JSON RPC 2.0 as defined in https://www.jsonrpc.org/specification. Main differences: * No support for batched requests. * No support for positional arguments passing. * No JSON RPC 1.0 fallback. """ import json import logging try: from keystonemiddleware import auth_token except ImportError: auth_token = None from oslo_config import cfg try: import oslo_messaging except ImportError: oslo_messaging = None from oslo_utils import strutils import webob from ironic_lib import auth_basic from ironic_lib.common.i18n import _ from ironic_lib import exception from ironic_lib import json_rpc from ironic_lib import wsgi CONF = cfg.CONF LOG = logging.getLogger(__name__) _DENY_LIST = {'init_host', 'del_host', 'target', 'iter_nodes'} def _build_method_map(manager): """Build mapping from method names to their bodies. :param manager: A conductor manager. :return: dict with mapping """ result = {} for method in dir(manager): if method.startswith('_') or method in _DENY_LIST: continue func = getattr(manager, method) if not callable(func): continue LOG.debug('Adding RPC method %s', method) result[method] = func return result class JsonRpcError(exception.IronicException): pass class ParseError(JsonRpcError): code = -32700 _msg_fmt = _("Invalid JSON received by RPC server") class InvalidRequest(JsonRpcError): code = -32600 _msg_fmt = _("Invalid request object received by RPC server") class MethodNotFound(JsonRpcError): code = -32601 _msg_fmt = _("Method %(name)s was not found") class InvalidParams(JsonRpcError): code = -32602 _msg_fmt = _("Params %(params)s are invalid for %(method)s: %(error)s") class EmptyContext: request_id = None def __init__(self, src): self.__dict__.update(src) def to_dict(self): return self.__dict__.copy() class WSGIService(wsgi.WSGIService): """Provides ability to launch JSON RPC as a WSGI application.""" def __init__(self, manager, serializer, context_class=EmptyContext): """Create a JSON RPC service. :param manager: Object from which to expose methods. :param serializer: A serializer that supports calls serialize_entity and deserialize_entity. :param context_class: A context class - a callable accepting a dict received from network. """ self.manager = manager self.serializer = serializer self.context_class = context_class self._method_map = _build_method_map(manager) auth_strategy = json_rpc.auth_strategy() if auth_strategy == 'keystone': conf = dict(CONF.keystone_authtoken) if auth_token is None: raise exception.ConfigInvalid( _("keystonemiddleware is required for keystone " "authentication")) app = auth_token.AuthProtocol(self._application, conf) elif auth_strategy == 'http_basic': app = auth_basic.BasicAuthMiddleware( self._application, cfg.CONF.json_rpc.http_basic_auth_user_file) else: app = self._application super().__init__('ironic-json-rpc', app, CONF.json_rpc) def _application(self, environment, start_response): """WSGI application for conductor JSON RPC.""" request = webob.Request(environment) if request.method != 'POST': body = {'error': {'code': 405, 'message': _('Only POST method can be used')}} return webob.Response(status_code=405, json_body=body)( environment, start_response) if json_rpc.auth_strategy() == 'keystone': roles = (request.headers.get('X-Roles') or '').split(',') allowed_roles = CONF.json_rpc.allowed_roles if set(roles).isdisjoint(allowed_roles): LOG.debug('Roles %s do not contain any of %s, rejecting ' 'request', roles, allowed_roles) body = {'error': {'code': 403, 'message': _('Forbidden')}} return webob.Response(status_code=403, json_body=body)( environment, start_response) result = self._call(request) if result is not None: response = webob.Response(content_type='application/json', charset='UTF-8', json_body=result) else: response = webob.Response(status_code=204) return response(environment, start_response) def _handle_error(self, exc, request_id=None): """Generate a JSON RPC 2.0 error body. :param exc: Exception object. :param request_id: ID of the request (if any). :return: dict with response body """ if (oslo_messaging is not None and isinstance(exc, oslo_messaging.ExpectedException)): exc = exc.exc_info[1] expected = isinstance(exc, exception.IronicException) cls = exc.__class__ if expected: LOG.debug('RPC error %s: %s', cls.__name__, exc) else: LOG.exception('Unexpected RPC exception %s', cls.__name__) response = { "jsonrpc": "2.0", "id": request_id, "error": { "code": getattr(exc, 'code', 500), "message": str(exc), } } if expected and not isinstance(exc, JsonRpcError): # Allow de-serializing the correct class for expected errors. response['error']['data'] = { 'class': '%s.%s' % (cls.__module__, cls.__name__) } return response def _call(self, request): """Process a JSON RPC request. :param request: ``webob.Request`` object. :return: dict with response body. """ request_id = None try: try: body = json.loads(request.text) except ValueError: LOG.error('Cannot parse JSON RPC request as JSON') raise ParseError() if not isinstance(body, dict): LOG.error('JSON RPC request %s is not an object (batched ' 'requests are not supported)', body) raise InvalidRequest() request_id = body.get('id') params = body.get('params', {}) if (body.get('jsonrpc') != '2.0' or not body.get('method') or not isinstance(params, dict)): LOG.error('JSON RPC request %s is invalid', body) raise InvalidRequest() except Exception as exc: # We do not treat malformed requests as notifications and return # a response even when request_id is None. This seems in agreement # with the examples in the specification. return self._handle_error(exc, request_id) try: method = body['method'] try: func = self._method_map[method] except KeyError: raise MethodNotFound(name=method) result = self._handle_requests(func, method, params) if request_id is not None: return { "jsonrpc": "2.0", "result": result, "id": request_id } except Exception as exc: result = self._handle_error(exc, request_id) # We treat correctly formed requests without "id" as notifications # and do not return any errors. if request_id is not None: return result def _handle_requests(self, func, name, params): """Convert arguments and call a method. :param func: Callable object. :param name: RPC call name for logging. :param params: Keyword arguments. :return: call result as JSON. """ # TODO(dtantsur): server-side version check? params.pop('rpc.version', None) logged_params = strutils.mask_dict_password(params) try: context = params.pop('context') except KeyError: context = None else: # A valid context is required for deserialization if not isinstance(context, dict): raise InvalidParams( _("Context must be a dictionary, if provided")) context = self.context_class(context) params = {key: self.serializer.deserialize_entity(context, value) for key, value in params.items()} params['context'] = context LOG.debug('RPC %s with %s', name, logged_params) try: result = func(**params) # FIXME(dtantsur): we could use the inspect module, but # oslo_messaging.expected_exceptions messes up signatures. except TypeError as exc: raise InvalidParams(params=', '.join(params), method=name, error=exc) if context is not None: # Currently it seems that we can serialize even with invalid # context, but I'm not sure it's guaranteed to be the case. result = self.serializer.serialize_entity(context, result) LOG.debug('RPC %s returned %s', name, strutils.mask_dict_password(result) if isinstance(result, dict) else result) return result