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