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)