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 38import http.client 39import json 40import logging 41import os 42import socket 43import time 44import urllib.parse 45 46HTTP_TIMEOUT = 30 47USER_AGENT = "AuthServiceProxy/0.1" 48 49log = logging.getLogger("BitcoinRPC") 50 51class JSONRPCException(Exception): 52 def __init__(self, rpc_error): 53 try: 54 errmsg = '%(message)s (%(code)i)' % rpc_error 55 except (KeyError, TypeError): 56 errmsg = '' 57 super().__init__(errmsg) 58 self.error = rpc_error 59 60 61def EncodeDecimal(o): 62 if isinstance(o, decimal.Decimal): 63 return str(o) 64 raise TypeError(repr(o) + " is not JSON serializable") 65 66class 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.__url = urllib.parse.urlparse(service_url) 75 user = None if self.__url.username is None else self.__url.username.encode('utf8') 76 passwd = None if self.__url.password is None else self.__url.password.encode('utf8') 77 authpair = user + b':' + passwd 78 self.__auth_header = b'Basic ' + base64.b64encode(authpair) 79 self.timeout = timeout 80 self._set_conn(connection) 81 82 def __getattr__(self, name): 83 if name.startswith('__') and name.endswith('__'): 84 # Python internal stuff 85 raise AttributeError 86 if self._service_name is not None: 87 name = "%s.%s" % (self._service_name, name) 88 return AuthServiceProxy(self.__service_url, name, connection=self.__conn) 89 90 def _request(self, method, path, postdata): 91 ''' 92 Do a HTTP request, with retry if we get disconnected (e.g. due to a timeout). 93 This is a workaround for https://bugs.python.org/issue3566 which is fixed in Python 3.5. 94 ''' 95 headers = {'Host': self.__url.hostname, 96 'User-Agent': USER_AGENT, 97 'Authorization': self.__auth_header, 98 'Content-type': 'application/json'} 99 if os.name == 'nt': 100 # Windows somehow does not like to re-use connections 101 # TODO: Find out why the connection would disconnect occasionally and make it reusable on Windows 102 self._set_conn() 103 try: 104 self.__conn.request(method, path, postdata, headers) 105 return self._get_response() 106 except http.client.BadStatusLine as e: 107 if e.line == "''": # if connection was closed, try again 108 self.__conn.close() 109 self.__conn.request(method, path, postdata, headers) 110 return self._get_response() 111 else: 112 raise 113 except (BrokenPipeError, ConnectionResetError): 114 # Python 3.5+ raises BrokenPipeError instead of BadStatusLine when the connection was reset 115 # ConnectionResetError happens on FreeBSD with Python 3.4 116 self.__conn.close() 117 self.__conn.request(method, path, postdata, headers) 118 return self._get_response() 119 120 def get_request(self, *args, **argsn): 121 AuthServiceProxy.__id_count += 1 122 123 log.debug("-%s-> %s %s" % (AuthServiceProxy.__id_count, self._service_name, 124 json.dumps(args, default=EncodeDecimal, ensure_ascii=self.ensure_ascii))) 125 if args and argsn: 126 raise ValueError('Cannot handle both named and positional arguments') 127 return {'version': '1.1', 128 'method': self._service_name, 129 'params': args or argsn, 130 'id': AuthServiceProxy.__id_count} 131 132 def __call__(self, *args, **argsn): 133 postdata = json.dumps(self.get_request(*args, **argsn), default=EncodeDecimal, ensure_ascii=self.ensure_ascii) 134 response = self._request('POST', self.__url.path, postdata.encode('utf-8')) 135 if response['error'] is not None: 136 raise JSONRPCException(response['error']) 137 elif 'result' not in response: 138 raise JSONRPCException({ 139 'code': -343, 'message': 'missing JSON-RPC result'}) 140 else: 141 return response['result'] 142 143 def batch(self, rpc_call_list): 144 postdata = json.dumps(list(rpc_call_list), default=EncodeDecimal, ensure_ascii=self.ensure_ascii) 145 log.debug("--> " + postdata) 146 return self._request('POST', self.__url.path, postdata.encode('utf-8')) 147 148 def _get_response(self): 149 req_start_time = time.time() 150 try: 151 http_response = self.__conn.getresponse() 152 except socket.timeout: 153 raise JSONRPCException({ 154 'code': -344, 155 'message': '%r RPC took longer than %f seconds. Consider ' 156 'using larger timeout for calls that take ' 157 'longer to return.' % (self._service_name, 158 self.__conn.timeout)}) 159 if http_response is None: 160 raise JSONRPCException({ 161 'code': -342, 'message': 'missing HTTP response from server'}) 162 163 content_type = http_response.getheader('Content-Type') 164 if content_type != 'application/json': 165 raise JSONRPCException({ 166 'code': -342, 'message': 'non-JSON HTTP response with \'%i %s\' from server' % (http_response.status, http_response.reason)}) 167 168 responsedata = http_response.read().decode('utf8') 169 response = json.loads(responsedata, parse_float=decimal.Decimal) 170 elapsed = time.time() - req_start_time 171 if "error" in response and response["error"] is None: 172 log.debug("<-%s- [%.6f] %s" % (response["id"], elapsed, json.dumps(response["result"], default=EncodeDecimal, ensure_ascii=self.ensure_ascii))) 173 else: 174 log.debug("<-- [%.6f] %s" % (elapsed, responsedata)) 175 return response 176 177 def __truediv__(self, relative_uri): 178 return AuthServiceProxy("{}/{}".format(self.__service_url, relative_uri), self._service_name, connection=self.__conn) 179 180 def _set_conn(self, connection=None): 181 port = 80 if self.__url.port is None else self.__url.port 182 if connection: 183 self.__conn = connection 184 self.timeout = connection.timeout 185 elif self.__url.scheme == 'https': 186 self.__conn = http.client.HTTPSConnection(self.__url.hostname, port, timeout=self.timeout) 187 else: 188 self.__conn = http.client.HTTPConnection(self.__url.hostname, port, timeout=self.timeout) 189