authproxy.py raw

   1  # Copyright (c) 2011 Jeff Garzik
   2  #
   3  # Previous copyright, from python-jsonrpc/jsonrpc/proxy.py:
   4  #
   5  # Copyright (c) 2007 Jan-Klaas Kollhof
   6  #
   7  # This file is part of jsonrpc.
   8  #
   9  # jsonrpc is free software; you can redistribute it and/or modify
  10  # it under the terms of the GNU Lesser General Public License as published by
  11  # the Free Software Foundation; either version 2.1 of the License, or
  12  # (at your option) any later version.
  13  #
  14  # This software is distributed in the hope that it will be useful,
  15  # but WITHOUT ANY WARRANTY; without even the implied warranty of
  16  # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  17  # GNU Lesser General Public License for more details.
  18  #
  19  # You should have received a copy of the GNU Lesser General Public License
  20  # along with this software; if not, write to the Free Software
  21  # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
  22  """HTTP proxy for opening RPC connection to bitcoind.
  23  
  24  AuthServiceProxy has the following improvements over python-jsonrpc's
  25  ServiceProxy class:
  26  
  27  - HTTP connections persist for the life of the AuthServiceProxy object
  28    (if server supports HTTP/1.1)
  29  - sends "jsonrpc":"2.0", per JSON-RPC 2.0
  30  - sends proper, incrementing 'id'
  31  - sends Basic HTTP authentication headers
  32  - parses all JSON numbers that look like floats as Decimal
  33  - uses standard Python json lib
  34  """
  35  
  36  import base64
  37  import decimal
  38  from http import HTTPStatus
  39  import http.client
  40  import json
  41  import logging
  42  import pathlib
  43  import socket
  44  import time
  45  import urllib.parse
  46  
  47  from .util import JSONRPCException, assert_equal
  48  
  49  HTTP_TIMEOUT = 30
  50  USER_AGENT = "AuthServiceProxy/0.1"
  51  
  52  log = logging.getLogger("BitcoinRPC")
  53  
  54  def serialization_fallback(o):
  55      if isinstance(o, decimal.Decimal):
  56          return str(o)
  57      if isinstance(o, pathlib.Path):
  58          return str(o)
  59      raise TypeError(repr(o) + " is not JSON serializable")
  60  
  61  class AuthServiceProxy():
  62      __id_count = 0
  63  
  64      # ensure_ascii: escape unicode as \uXXXX, passed to json.dumps
  65      def __init__(self, service_url, service_name=None, timeout=HTTP_TIMEOUT, connection=None, ensure_ascii=True):
  66          self.__service_url = service_url
  67          self._service_name = service_name
  68          self.ensure_ascii = ensure_ascii  # can be toggled on the fly by tests
  69          self.reuse_http_connections = True
  70          self.__url = urllib.parse.urlparse(service_url)
  71          user = None if self.__url.username is None else self.__url.username.encode('utf8')
  72          passwd = None if self.__url.password is None else self.__url.password.encode('utf8')
  73          authpair = user + b':' + passwd
  74          self.__auth_header = b'Basic ' + base64.b64encode(authpair)
  75          # clamp the socket timeout, since larger values can cause an
  76          # "Invalid argument" exception in Python's HTTP(S) client
  77          # library on some operating systems (e.g. OpenBSD, FreeBSD)
  78          self.timeout = min(timeout, 2147483)
  79          self._set_conn(connection)
  80  
  81      def __getattr__(self, name):
  82          if name.startswith('__') and name.endswith('__'):
  83              # Python internal stuff
  84              raise AttributeError
  85          if self._service_name is not None:
  86              name = "%s.%s" % (self._service_name, name)
  87          if not self.reuse_http_connections:
  88              self._set_conn()
  89          return AuthServiceProxy(self.__service_url, name, connection=self.__conn)
  90  
  91      def _request(self, method, path, postdata):
  92          '''
  93          Do a HTTP request.
  94          '''
  95          headers = {'Host': self.__url.hostname,
  96                     'User-Agent': USER_AGENT,
  97                     'Authorization': self.__auth_header,
  98                     'Content-type': 'application/json'}
  99          if not self.reuse_http_connections:
 100              self._set_conn()
 101          self.__conn.request(method, path, postdata, headers)
 102          return self._get_response()
 103  
 104      def _json_dumps(self, obj):
 105          return json.dumps(obj, default=serialization_fallback, ensure_ascii=self.ensure_ascii)
 106  
 107      def get_request(self, *args, **argsn):
 108          AuthServiceProxy.__id_count += 1
 109  
 110          log.debug("-{}-> {} {} {}".format(
 111              AuthServiceProxy.__id_count,
 112              self._service_name,
 113              self._json_dumps(args),
 114              self._json_dumps(argsn),
 115          ))
 116  
 117          if args and argsn:
 118              params = dict(args=args, **argsn)
 119          else:
 120              params = args or argsn
 121          return {'jsonrpc': '2.0',
 122                  'method': self._service_name,
 123                  'params': params,
 124                  'id': AuthServiceProxy.__id_count}
 125  
 126      def __call__(self, *args, **argsn):
 127          postdata = self._json_dumps(self.get_request(*args, **argsn))
 128          response, status = self._request('POST', self.__url.path, postdata.encode('utf-8'))
 129          # For backwards compatibility tests, accept JSON RPC 1.1 responses
 130          if 'jsonrpc' not in response:
 131              if response['error'] is not None:
 132                  raise JSONRPCException(response['error'], status)
 133              elif 'result' not in response:
 134                  raise JSONRPCException({
 135                      'code': -343, 'message': 'missing JSON-RPC result'}, status)
 136              elif status != HTTPStatus.OK:
 137                  raise JSONRPCException({
 138                      'code': -342, 'message': 'non-200 HTTP status code but no JSON-RPC error'}, status)
 139              else:
 140                  return response['result']
 141          else:
 142              assert_equal(response['jsonrpc'], '2.0')
 143              if status != HTTPStatus.OK:
 144                  raise JSONRPCException({
 145                      'code': -342, 'message': 'non-200 HTTP status code'}, status)
 146              if 'error' in response:
 147                  raise JSONRPCException(response['error'], status)
 148              elif 'result' not in response:
 149                  raise JSONRPCException({
 150                      'code': -343, 'message': 'missing JSON-RPC 2.0 result and error'}, status)
 151              return response['result']
 152  
 153      def batch(self, rpc_call_list):
 154          postdata = self._json_dumps(list(rpc_call_list))
 155          log.debug("--> " + postdata)
 156          response, status = self._request('POST', self.__url.path, postdata.encode('utf-8'))
 157          if status != HTTPStatus.OK:
 158              raise JSONRPCException({
 159                  'code': -342, 'message': 'non-200 HTTP status code'}, status)
 160          return response
 161  
 162      def _get_response(self):
 163          req_start_time = time.time()
 164          try:
 165              http_response = self.__conn.getresponse()
 166          except socket.timeout:
 167              raise JSONRPCException({
 168                  'code': -344,
 169                  'message': '%r RPC took longer than %f seconds. Consider '
 170                             'using larger timeout for calls that take '
 171                             'longer to return.' % (self._service_name,
 172                                                    self.__conn.timeout)}) from None
 173          if http_response is None:
 174              raise JSONRPCException({
 175                  'code': -342, 'message': 'missing HTTP response from server'})
 176  
 177          # Check for no-content HTTP status code, which can be returned when an
 178          # RPC client requests a JSON-RPC 2.0 "notification" with no response.
 179          # Currently this is only possible if clients call the _request() method
 180          # directly to send a raw request.
 181          if http_response.status == HTTPStatus.NO_CONTENT:
 182              if len(http_response.read()) != 0:
 183                  raise JSONRPCException({'code': -342, 'message': 'Content received with NO CONTENT status code'})
 184              return None, http_response.status
 185  
 186          content_type = http_response.getheader('Content-Type')
 187          if content_type != 'application/json':
 188              raise JSONRPCException(
 189                  {'code': -342, 'message': f"non-JSON HTTP response with \'{http_response.status} {http_response.reason}\' from server: {http_response.read().decode()}"},
 190                  http_response.status)
 191  
 192          data = http_response.read()
 193          try:
 194              responsedata = data.decode('utf8')
 195          except UnicodeDecodeError as e:
 196              raise JSONRPCException({
 197                  'code': -342, 'message': f'Cannot decode response in utf8 format, content: {data}, exception: {e}'})
 198          response = json.loads(responsedata, parse_float=decimal.Decimal)
 199          elapsed = time.time() - req_start_time
 200          if "error" in response and response["error"] is None:
 201              log.debug("<-%s- [%.6f] %s" % (response["id"], elapsed, self._json_dumps(response["result"])))
 202          else:
 203              log.debug("<-- [%.6f] %s" % (elapsed, responsedata))
 204          return response, http_response.status
 205  
 206      def __truediv__(self, relative_uri):
 207          return AuthServiceProxy("{}/{}".format(self.__service_url, relative_uri), self._service_name, connection=self.__conn)
 208  
 209      def _set_conn(self, connection=None):
 210          port = 80 if self.__url.port is None else self.__url.port
 211          if connection:
 212              self.__conn = connection
 213              self.timeout = connection.timeout
 214          elif self.__url.scheme == 'https':
 215              self.__conn = http.client.HTTPSConnection(self.__url.hostname, port, timeout=self.timeout)
 216          else:
 217              self.__conn = http.client.HTTPConnection(self.__url.hostname, port, timeout=self.timeout)
 218