/ test / functional / test_framework / authproxy.py
authproxy.py
  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  HTTP_TIMEOUT = 30
 48  USER_AGENT = "AuthServiceProxy/0.1"
 49  
 50  log = logging.getLogger("BitcoinRPC")
 51  
 52  class JSONRPCException(Exception):
 53      def __init__(self, rpc_error, http_status=None):
 54          super().__init__(f"{rpc_error} [http_status={http_status}]")
 55          self.error = rpc_error
 56          self.http_status = http_status
 57  
 58  
 59  def serialization_fallback(o):
 60      if isinstance(o, decimal.Decimal):
 61          return str(o)
 62      if isinstance(o, pathlib.Path):
 63          return str(o)
 64      raise TypeError(repr(o) + " is not JSON serializable")
 65  
 66  class AuthServiceProxy():
 67      __id_count = 0
 68  
 69      # ensure_ascii: escape unicode as \uXXXX, passed to json.dumps
 70      def __init__(self, service_url, service_name=None, timeout=HTTP_TIMEOUT, connection=None, ensure_ascii=True):
 71          self.__service_url = service_url
 72          self._service_name = service_name
 73          self.ensure_ascii = ensure_ascii  # can be toggled on the fly by tests
 74          self.reuse_http_connections = True
 75          self.__url = urllib.parse.urlparse(service_url)
 76          user = None if self.__url.username is None else self.__url.username.encode('utf8')
 77          passwd = None if self.__url.password is None else self.__url.password.encode('utf8')
 78          authpair = user + b':' + passwd
 79          self.__auth_header = b'Basic ' + base64.b64encode(authpair)
 80          # clamp the socket timeout, since larger values can cause an
 81          # "Invalid argument" exception in Python's HTTP(S) client
 82          # library on some operating systems (e.g. OpenBSD, FreeBSD)
 83          self.timeout = min(timeout, 2147483)
 84          self._set_conn(connection)
 85  
 86      def __getattr__(self, name):
 87          if name.startswith('__') and name.endswith('__'):
 88              # Python internal stuff
 89              raise AttributeError
 90          if self._service_name is not None:
 91              name = "%s.%s" % (self._service_name, name)
 92          if not self.reuse_http_connections:
 93              self._set_conn()
 94          return AuthServiceProxy(self.__service_url, name, connection=self.__conn)
 95  
 96      def _request(self, method, path, postdata):
 97          '''
 98          Do a HTTP request.
 99          '''
100          headers = {'Host': self.__url.hostname,
101                     'User-Agent': USER_AGENT,
102                     'Authorization': self.__auth_header,
103                     'Content-type': 'application/json'}
104          if not self.reuse_http_connections:
105              self._set_conn()
106          self.__conn.request(method, path, postdata, headers)
107          return self._get_response()
108  
109      def _json_dumps(self, obj):
110          return json.dumps(obj, default=serialization_fallback, ensure_ascii=self.ensure_ascii)
111  
112      def get_request(self, *args, **argsn):
113          AuthServiceProxy.__id_count += 1
114  
115          log.debug("-{}-> {} {} {}".format(
116              AuthServiceProxy.__id_count,
117              self._service_name,
118              self._json_dumps(args),
119              self._json_dumps(argsn),
120          ))
121  
122          if args and argsn:
123              params = dict(args=args, **argsn)
124          else:
125              params = args or argsn
126          return {'jsonrpc': '2.0',
127                  'method': self._service_name,
128                  'params': params,
129                  'id': AuthServiceProxy.__id_count}
130  
131      def __call__(self, *args, **argsn):
132          postdata = self._json_dumps(self.get_request(*args, **argsn))
133          response, status = self._request('POST', self.__url.path, postdata.encode('utf-8'))
134          # For backwards compatibility tests, accept JSON RPC 1.1 responses
135          if 'jsonrpc' not in response:
136              if response['error'] is not None:
137                  raise JSONRPCException(response['error'], status)
138              elif 'result' not in response:
139                  raise JSONRPCException({
140                      'code': -343, 'message': 'missing JSON-RPC result'}, status)
141              elif status != HTTPStatus.OK:
142                  raise JSONRPCException({
143                      'code': -342, 'message': 'non-200 HTTP status code but no JSON-RPC error'}, status)
144              else:
145                  return response['result']
146          else:
147              from .util import assert_equal
148              assert_equal(response['jsonrpc'], '2.0')
149              if status != HTTPStatus.OK:
150                  raise JSONRPCException({
151                      'code': -342, 'message': 'non-200 HTTP status code'}, status)
152              if 'error' in response:
153                  raise JSONRPCException(response['error'], status)
154              elif 'result' not in response:
155                  raise JSONRPCException({
156                      'code': -343, 'message': 'missing JSON-RPC 2.0 result and error'}, status)
157              return response['result']
158  
159      def batch(self, rpc_call_list):
160          postdata = self._json_dumps(list(rpc_call_list))
161          log.debug("--> " + postdata)
162          response, status = self._request('POST', self.__url.path, postdata.encode('utf-8'))
163          if status != HTTPStatus.OK:
164              raise JSONRPCException({
165                  'code': -342, 'message': 'non-200 HTTP status code'}, status)
166          return response
167  
168      def _get_response(self):
169          req_start_time = time.time()
170          try:
171              http_response = self.__conn.getresponse()
172          except socket.timeout:
173              raise JSONRPCException({
174                  'code': -344,
175                  'message': '%r RPC took longer than %f seconds. Consider '
176                             'using larger timeout for calls that take '
177                             'longer to return.' % (self._service_name,
178                                                    self.__conn.timeout)})
179          if http_response is None:
180              raise JSONRPCException({
181                  'code': -342, 'message': 'missing HTTP response from server'})
182  
183          # Check for no-content HTTP status code, which can be returned when an
184          # RPC client requests a JSON-RPC 2.0 "notification" with no response.
185          # Currently this is only possible if clients call the _request() method
186          # directly to send a raw request.
187          if http_response.status == HTTPStatus.NO_CONTENT:
188              if len(http_response.read()) != 0:
189                  raise JSONRPCException({'code': -342, 'message': 'Content received with NO CONTENT status code'})
190              return None, http_response.status
191  
192          content_type = http_response.getheader('Content-Type')
193          if content_type != 'application/json':
194              raise JSONRPCException(
195                  {'code': -342, 'message': f"non-JSON HTTP response with \'{http_response.status} {http_response.reason}\' from server: {http_response.read().decode()}"},
196                  http_response.status)
197  
198          data = http_response.read()
199          try:
200              responsedata = data.decode('utf8')
201          except UnicodeDecodeError as e:
202              raise JSONRPCException({
203                  'code': -342, 'message': f'Cannot decode response in utf8 format, content: {data}, exception: {e}'})
204          response = json.loads(responsedata, parse_float=decimal.Decimal)
205          elapsed = time.time() - req_start_time
206          if "error" in response and response["error"] is None:
207              log.debug("<-%s- [%.6f] %s" % (response["id"], elapsed, self._json_dumps(response["result"])))
208          else:
209              log.debug("<-- [%.6f] %s" % (elapsed, responsedata))
210          return response, http_response.status
211  
212      def __truediv__(self, relative_uri):
213          return AuthServiceProxy("{}/{}".format(self.__service_url, relative_uri), self._service_name, connection=self.__conn)
214  
215      def _set_conn(self, connection=None):
216          port = 80 if self.__url.port is None else self.__url.port
217          if connection:
218              self.__conn = connection
219              self.timeout = connection.timeout
220          elif self.__url.scheme == 'https':
221              self.__conn = http.client.HTTPSConnection(self.__url.hostname, port, timeout=self.timeout)
222          else:
223              self.__conn = http.client.HTTPConnection(self.__url.hostname, port, timeout=self.timeout)