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