Move AGPL compliance code out of HTTPServer to a new agplcompliance.py file
[bitcoin:eloipool.git] / httpserver.py
1 # Eloipool - Python Bitcoin pool server
2 # Copyright (C) 2011-2013  Luke Dashjr <luke-jr+eloipool@utopios.org>
3 #
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as
6 # published by the Free Software Foundation, either version 3 of the
7 # License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU Affero General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
16
17 import agplcompliance
18 from base64 import b64decode
19 from datetime import datetime
20 from email.utils import formatdate
21 from gzip import GzipFile
22 import io
23 import logging
24 import networkserver
25 import os
26 import re
27 import stat
28 from struct import pack
29 from time import mktime, time
30 import traceback
31
32 class AsyncRequest(BaseException):
33         pass
34
35 class RequestAlreadyHandled(BaseException):
36         pass
37
38 class RequestHandled(RequestAlreadyHandled):
39         pass
40
41 class RequestNotHandled(BaseException):
42         pass
43
44 class HTTPHandler(networkserver.SocketHandler):
45         HTTPStatus = {
46                 200: 'OK',
47                 401: 'Unauthorized',
48                 404: 'Not Found',
49                 405: 'Method Not Allowed',
50                 500: 'Internal Server Error',
51         }
52         
53         logger = logging.getLogger('HTTPHandler')
54         
55         default_quirks = {}
56         
57         def sendReply(self, status=200, body=b'', headers=None, tryCompression=True):
58                 if self.replySent:
59                         raise RequestAlreadyHandled
60                 buf = "HTTP/1.1 %d %s\r\n" % (status, self.HTTPStatus.get(status, 'Unknown'))
61                 headers = dict(headers) if headers else {}
62                 headers['Date'] = formatdate(timeval=mktime(datetime.now().timetuple()), localtime=False, usegmt=True)
63                 headers.setdefault('Server', 'Eloipool')
64                 if not agplcompliance._SourceFiles is None:
65                         headers.setdefault('X-Source-Code', '/src/')
66                 if body is None:
67                         headers.setdefault('Transfer-Encoding', 'chunked')
68                 else:
69                         if tryCompression and 'gzip' in self.quirks:
70                                 headers['Content-Encoding'] = 'gzip'
71                                 headers['Vary'] = 'Content-Encoding'
72                                 gz = io.BytesIO()
73                                 with GzipFile(fileobj=gz, mode='wb') as raw:
74                                         raw.write(body)
75                                 body = gz.getvalue()
76                         headers['Content-Length'] = len(body)
77                 for k, v in headers.items():
78                         if v is None: continue
79                         buf += "%s: %s\r\n" % (k, v)
80                 buf += "\r\n"
81                 buf = buf.encode('utf8')
82                 self.replySent = True
83                 if body is None:
84                         self.push(buf)
85                         return
86                 buf += body
87                 self.push(buf)
88                 raise RequestHandled
89         
90         def doError(self, reason = '', code = 100, headers = None):
91                 if headers is None: headers = {}
92                 headers.setdefault('Content-Type', 'text/plain')
93                 return self.sendReply(500, reason.encode('utf8'), headers)
94         
95         def doHeader_accept_encoding(self, value):
96                 if b'gzip' in value:
97                         self.quirks['gzip'] = True
98         
99         def checkAuthentication(self, *a, **ka):
100                 return self.server.checkAuthentication(*a, **ka)
101         
102         def doHeader_authorization(self, value):
103                 value = value.split(b' ')
104                 if len(value) != 2 or value[0] != b'Basic':
105                         return self.doError('Bad Authorization header')
106                 value = b64decode(value[1])
107                 (un, pw, *x) = value.split(b':', 1) + [None]
108                 valid = False
109                 try:
110                         valid = self.checkAuthentication(un, pw)
111                 except:
112                         return self.doError('Error checking authorization')
113                 if valid:
114                         self.Username = un.decode('utf8')
115         
116         def doHeader_connection(self, value):
117                 if value == b'close':
118                         self.quirks['close'] = None
119         
120         def doHeader_content_length(self, value):
121                 self.CL = int(value)
122         
123         def doHeader_x_forwarded_for(self, value):
124                 if self.addr[0] in self.server.TrustedForwarders:
125                         self.remoteHost = value.decode('ascii')
126                 else:
127                         self.logger.debug("Ignoring X-Forwarded-For header from %s" % (self.addr[0],))
128         
129         def doAuthenticate(self):
130                 self.sendReply(401, headers={'WWW-Authenticate': 'Basic realm="%s"' % (self.server.ServerName,)})
131         
132         def parse_headers(self, hs):
133                 self.CL = None
134                 self.Username = None
135                 self.method = None
136                 self.path = None
137                 hs = re.split(br'\r?\n', hs)
138                 data = hs.pop(0).split(b' ')
139                 try:
140                         self.method = data[0]
141                         self.path = data[1]
142                 except IndexError:
143                         self.close()
144                         return
145                 self.extensions = []
146                 self.reqinfo = {}
147                 self.quirks = dict(self.default_quirks)
148                 if data[2:] != [b'HTTP/1.1']:
149                         self.quirks['close'] = None
150                 while True:
151                         try:
152                                 data = hs.pop(0)
153                         except IndexError:
154                                 break
155                         data = tuple(map(lambda a: a.strip(), data.split(b':', 1)))
156                         method = 'doHeader_' + data[0].decode('ascii').lower()
157                         if hasattr(self, method):
158                                 try:
159                                         getattr(self, method)(data[1])
160                                 except RequestAlreadyHandled:
161                                         # Ignore multiple errors and such
162                                         pass
163         
164         def found_terminator(self):
165                 if self.reading_headers:
166                         inbuf = b"".join(self.incoming)
167                         self.incoming = []
168                         m = re.match(br'^[\r\n]+', inbuf)
169                         if m:
170                                 inbuf = inbuf[len(m.group(0)):]
171                         if not inbuf:
172                                 return
173                         
174                         self.reading_headers = False
175                         self.parse_headers(inbuf)
176                         if self.CL:
177                                 self.set_terminator(self.CL)
178                                 return
179                 
180                 self.set_terminator(None)
181                 try:
182                         self.handle_request()
183                         raise RequestNotHandled
184                 except RequestHandled:
185                         self.reset_request()
186                 except AsyncRequest:
187                         pass
188                 except:
189                         self.logger.error(traceback.format_exc())
190         
191         def handle_src_request(self):
192                 # For AGPL compliance, allow direct downloads of source code
193                 p = self.path[5:]
194                 s = agplcompliance.get_source(p)
195                 if s is None:
196                         return self.sendReply(404)
197                 (ct, body) = s
198                 return self.sendReply(body=body, headers={'Content-Type':ct})
199         
200         def reset_request(self):
201                 self.replySent = False
202                 self.incoming = []
203                 self.set_terminator( (b"\n\n", b"\r\n\r\n") )
204                 self.reading_headers = True
205                 self.changeTask(self.handle_timeout, time() + 150)
206                 if 'close' in self.quirks:
207                         self.close()
208                 # proxies can do multiple requests in one connection for multiple clients, so reset address every time
209                 self.remoteHost = self.addr[0]
210         
211         def __init__(self, *a, **ka):
212                 super().__init__(*a, **ka)
213                 self.quirks = dict(self.default_quirks)
214                 self.reset_request()
215         
216 setattr(HTTPHandler, 'doHeader_accept-encoding', HTTPHandler.doHeader_accept_encoding);
217 setattr(HTTPHandler, 'doHeader_content-length', HTTPHandler.doHeader_content_length);
218 setattr(HTTPHandler, 'doHeader_x-forwarded-for', HTTPHandler.doHeader_x_forwarded_for);