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 24AuthServiceProxy has the following improvements over python-jsonrpc's 25ServiceProxy class: 26 27- HTTP connections persist for the life of the AuthServiceProxy object 28 (if server supports HTTP/1.1) 29- sends protocol 'version', per JSON-RPC 1.1 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 36import base64 37import decimal 38from http import HTTPStatus 39import http.client 40import json 41import logging 42import os 43import socket 44import time 45import urllib.parse 46 47HTTP_TIMEOUT = 30 48USER_AGENT = "AuthServiceProxy/0.1" 49 50log = logging.getLogger("BitcoinRPC") 51 52class 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 63def EncodeDecimal(o): 64 if isinstance(o, decimal.Decimal): 65 return str(o) 66 raise TypeError(repr(o) + " is not JSON serializable") 67 68class AuthServiceProxy(): 69 __id_count = 0 70 71 # ensure_ascii: escape unicode as \uXXXX, passed to json.dumps 72 def __init__(self, service_url, service_name=None, timeout=HTTP_TIMEOUT, connection=None, ensure_ascii=True): 73 self.__service_url = service_url 74 self._service_name = service_name 75 self.ensure_ascii = ensure_ascii # can be toggled on the fly by tests 76 self.__url = urllib.parse.urlparse(service_url) 77 user = None if self.__url.username is None else self.__url.username.encode('utf8') 78 passwd = None if self.__url.password is None else self.__url.password.encode('utf8') 79 authpair = user + b':' + passwd 80 self.__auth_header = b'Basic ' + base64.b64encode(authpair) 81 self.timeout = timeout 82 self._set_conn(connection) 83 84 def __getattr__(self, name): 85 if name.startswith('__') and name.endswith('__'): 86 # Python internal stuff 87 raise AttributeError 88 if self._service_name is not None: 89 name = "%s.%s" % (self._service_name, name) 90 return AuthServiceProxy(self.__service_url, name, connection=self.__conn) 91 92 def _request(self, method, path, postdata): 93 ''' 94 Do a HTTP request, with retry if we get disconnected (e.g. due to a timeout). 95 This is a workaround for https://bugs.python.org/issue3566 which is fixed in Python 3.5. 96 ''' 97 headers = {'Host': self.__url.hostname, 98 'User-Agent': USER_AGENT, 99 'Authorization': self.__auth_header, 100 'Content-type': 'application/json'} 101 if os.name == 'nt': 102 # Windows somehow does not like to re-use connections 103 # TODO: Find out why the connection would disconnect occasionally and make it reusable on Windows 104 # Avoid "ConnectionAbortedError: [WinError 10053] An established connection was aborted by the software in your host machine" 105 self._set_conn() 106 try: 107 self.__conn.request(method, path, postdata, headers) 108 return self._get_response() 109 except (BrokenPipeError, ConnectionResetError): 110 # Python 3.5+ raises BrokenPipeError when the connection was reset 111 # ConnectionResetError happens on FreeBSD 112 self.__conn.close() 113 self.__conn.request(method, path, postdata, headers) 114 return self._get_response() 115 except OSError as e: 116 retry = ( 117 '[WinError 10053] An established connection was aborted by the software in your host machine' in str(e)) 118 # Workaround for a bug on macOS. See https://bugs.python.org/issue33450 119 retry = retry or ('[Errno 41] Protocol wrong type for socket' in str(e)) 120 if retry: 121 self.__conn.close() 122 self.__conn.request(method, path, postdata, headers) 123 return self._get_response() 124 else: 125 raise 126 127 def get_request(self, *args, **argsn): 128 AuthServiceProxy.__id_count += 1 129 130 log.debug("-{}-> {} {}".format( 131 AuthServiceProxy.__id_count, 132 self._service_name, 133 json.dumps(args or argsn, default=EncodeDecimal, ensure_ascii=self.ensure_ascii), 134 )) 135 if args and argsn: 136 raise ValueError('Cannot handle both named and positional arguments') 137 return {'version': '1.1', 138 'method': self._service_name, 139 'params': args or argsn, 140 'id': AuthServiceProxy.__id_count} 141 142 def __call__(self, *args, **argsn): 143 postdata = json.dumps(self.get_request(*args, **argsn), default=EncodeDecimal, ensure_ascii=self.ensure_ascii) 144 response, status = self._request('POST', self.__url.path, postdata.encode('utf-8')) 145 if response['error'] is not None: 146 raise JSONRPCException(response['error'], status) 147 elif 'result' not in response: 148 raise JSONRPCException({ 149 'code': -343, 'message': 'missing JSON-RPC result'}, status) 150 elif status != HTTPStatus.OK: 151 raise JSONRPCException({ 152 'code': -342, 'message': 'non-200 HTTP status code but no JSON-RPC error'}, status) 153 else: 154 return response['result'] 155 156 def batch(self, rpc_call_list): 157 postdata = json.dumps(list(rpc_call_list), default=EncodeDecimal, ensure_ascii=self.ensure_ascii) 158 log.debug("--> " + postdata) 159 response, status = self._request('POST', self.__url.path, postdata.encode('utf-8')) 160 if status != HTTPStatus.OK: 161 raise JSONRPCException({ 162 'code': -342, 'message': 'non-200 HTTP status code but no JSON-RPC error'}, status) 163 return response 164 165 def _get_response(self): 166 req_start_time = time.time() 167 try: 168 http_response = self.__conn.getresponse() 169 except socket.timeout: 170 raise JSONRPCException({ 171 'code': -344, 172 'message': '%r RPC took longer than %f seconds. Consider ' 173 'using larger timeout for calls that take ' 174 'longer to return.' % (self._service_name, 175 self.__conn.timeout)}) 176 if http_response is None: 177 raise JSONRPCException({ 178 'code': -342, 'message': 'missing HTTP response from server'}) 179 180 content_type = http_response.getheader('Content-Type') 181 if content_type != 'application/json': 182 raise JSONRPCException( 183 {'code': -342, 'message': 'non-JSON HTTP response with \'%i %s\' from server' % (http_response.status, http_response.reason)}, 184 http_response.status) 185 186 responsedata = http_response.read().decode('utf8') 187 response = json.loads(responsedata, parse_float=decimal.Decimal) 188 elapsed = time.time() - req_start_time 189 if "error" in response and response["error"] is None: 190 log.debug("<-%s- [%.6f] %s" % (response["id"], elapsed, json.dumps(response["result"], default=EncodeDecimal, ensure_ascii=self.ensure_ascii))) 191 else: 192 log.debug("<-- [%.6f] %s" % (elapsed, responsedata)) 193 return response, http_response.status 194 195 def __truediv__(self, relative_uri): 196 return AuthServiceProxy("{}/{}".format(self.__service_url, relative_uri), self._service_name, connection=self.__conn) 197 198 def _set_conn(self, connection=None): 199 port = 80 if self.__url.port is None else self.__url.port 200 if connection: 201 self.__conn = connection 202 self.timeout = connection.timeout 203 elif self.__url.scheme == 'https': 204 self.__conn = http.client.HTTPSConnection(self.__url.hostname, port, timeout=self.timeout) 205 else: 206 self.__conn = http.client.HTTPConnection(self.__url.hostname, port, timeout=self.timeout) 207