/ 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              assert response['jsonrpc'] == '2.0'
148              if status != HTTPStatus.OK:
149                  raise JSONRPCException({
150                      'code': -342, 'message': 'non-200 HTTP status code'}, status)
151              if 'error' in response:
152                  raise JSONRPCException(response['error'], status)
153              elif 'result' not in response:
154                  raise JSONRPCException({
155                      'code': -343, 'message': 'missing JSON-RPC 2.0 result and error'}, status)
156              return response['result']
157  
158      def batch(self, rpc_call_list):
159          postdata = self._json_dumps(list(rpc_call_list))
160          log.debug("--> " + postdata)
161          response, status = self._request('POST', self.__url.path, postdata.encode('utf-8'))
162          if status != HTTPStatus.OK:
163              raise JSONRPCException({
164                  'code': -342, 'message': 'non-200 HTTP status code'}, status)
165          return response
166  
167      def _get_response(self):
168          req_start_time = time.time()
169          try:
170              http_response = self.__conn.getresponse()
171          except socket.timeout:
172              raise JSONRPCException({
173                  'code': -344,
174                  'message': '%r RPC took longer than %f seconds. Consider '
175                             'using larger timeout for calls that take '
176                             'longer to return.' % (self._service_name,
177                                                    self.__conn.timeout)})
178          if http_response is None:
179              raise JSONRPCException({
180                  'code': -342, 'message': 'missing HTTP response from server'})
181  
182          # Check for no-content HTTP status code, which can be returned when an
183          # RPC client requests a JSON-RPC 2.0 "notification" with no response.
184          # Currently this is only possible if clients call the _request() method
185          # directly to send a raw request.
186          if http_response.status == HTTPStatus.NO_CONTENT:
187              if len(http_response.read()) != 0:
188                  raise JSONRPCException({'code': -342, 'message': 'Content received with NO CONTENT status code'})
189              return None, http_response.status
190  
191          content_type = http_response.getheader('Content-Type')
192          if content_type != 'application/json':
193              raise JSONRPCException(
194                  {'code': -342, 'message': f"non-JSON HTTP response with \'{http_response.status} {http_response.reason}\' from server: {http_response.read().decode()}"},
195                  http_response.status)
196  
197          data = http_response.read()
198          try:
199              responsedata = data.decode('utf8')
200          except UnicodeDecodeError as e:
201              raise JSONRPCException({
202                  'code': -342, 'message': f'Cannot decode response in utf8 format, content: {data}, exception: {e}'})
203          response = json.loads(responsedata, parse_float=decimal.Decimal)
204          elapsed = time.time() - req_start_time
205          if "error" in response and response["error"] is None:
206              log.debug("<-%s- [%.6f] %s" % (response["id"], elapsed, self._json_dumps(response["result"])))
207          else:
208              log.debug("<-- [%.6f] %s" % (elapsed, responsedata))
209          return response, http_response.status
210  
211      def __truediv__(self, relative_uri):
212          return AuthServiceProxy("{}/{}".format(self.__service_url, relative_uri), self._service_name, connection=self.__conn)
213  
214      def _set_conn(self, connection=None):
215          port = 80 if self.__url.port is None else self.__url.port
216          if connection:
217              self.__conn = connection
218              self.timeout = connection.timeout
219          elif self.__url.scheme == 'https':
220              self.__conn = http.client.HTTPSConnection(self.__url.hostname, port, timeout=self.timeout)
221          else:
222              self.__conn = http.client.HTTPConnection(self.__url.hostname, port, timeout=self.timeout)