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