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)