Bugfix: authorization: Handle utf8 decoding outside of generic modules, safe if passw...
[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 from base64 import b64decode
18 from datetime import datetime
19 from email.utils import formatdate
20 from gzip import GzipFile
21 import io
22 import logging
23 import networkserver
24 import os
25 import re
26 import stat
27 from struct import pack
28 from time import mktime, time
29 import traceback
30
31 # It is not legal to bypass or lie to this check. See LICENSE file for details.
32 try:
33         _srcdir = os.path.dirname(os.path.abspath(__file__))
34         if os.path.exists(_srcdir + '/.I_swear_that_I_am_Luke_Dashjr'):
35                 _SourceFiles = None
36         else:
37                 _SourceFiles = os.popen('cd \'%s\' && git ls-files' % (_srcdir,)).read().split('\n')
38                 try:
39                         _SourceFiles.remove('')
40                 except ValueError:
41                         pass
42                 if len(_SourceFiles) < 2:
43                         raise RuntimeError('Unknown error')
44                 _SourceFiles = tuple(x.encode('utf8') for x in _SourceFiles)
45                 _GitDesc = os.popen('cd \'%s\' && git describe --dirty --always' % (_srcdir,)).read().strip().encode('utf8')
46 except BaseException as e:
47         logging.getLogger('Licensing').critical('Error getting list of source files! AGPL requires this. To fix, be sure you are using git for Eloipool.\n' + traceback.format_exc())
48         import sys
49         sys.exit(1)
50
51 class AsyncRequest(BaseException):
52         pass
53
54 class RequestAlreadyHandled(BaseException):
55         pass
56
57 class RequestHandled(RequestAlreadyHandled):
58         pass
59
60 class RequestNotHandled(BaseException):
61         pass
62
63 class HTTPHandler(networkserver.SocketHandler):
64         HTTPStatus = {
65                 200: 'OK',
66                 401: 'Unauthorized',
67                 404: 'Not Found',
68                 405: 'Method Not Allowed',
69                 500: 'Internal Server Error',
70         }
71         
72         logger = logging.getLogger('HTTPHandler')
73         
74         default_quirks = {}
75         
76         def sendReply(self, status=200, body=b'', headers=None, tryCompression=True):
77                 if self.replySent:
78                         raise RequestAlreadyHandled
79                 buf = "HTTP/1.1 %d %s\r\n" % (status, self.HTTPStatus.get(status, 'Unknown'))
80                 headers = dict(headers) if headers else {}
81                 headers['Date'] = formatdate(timeval=mktime(datetime.now().timetuple()), localtime=False, usegmt=True)
82                 headers.setdefault('Server', 'Eloipool')
83                 if not _SourceFiles is None:
84                         headers.setdefault('X-Source-Code', '/src/')
85                 if body is None:
86                         headers.setdefault('Transfer-Encoding', 'chunked')
87                 else:
88                         if tryCompression and 'gzip' in self.quirks:
89                                 headers['Content-Encoding'] = 'gzip'
90                                 headers['Vary'] = 'Content-Encoding'
91                                 gz = io.BytesIO()
92                                 with GzipFile(fileobj=gz, mode='wb') as raw:
93                                         raw.write(body)
94                                 body = gz.getvalue()
95                         headers['Content-Length'] = len(body)
96                 for k, v in headers.items():
97                         if v is None: continue
98                         buf += "%s: %s\r\n" % (k, v)
99                 buf += "\r\n"
100                 buf = buf.encode('utf8')
101                 self.replySent = True
102                 if body is None:
103                         self.push(buf)
104                         return
105                 buf += body
106                 self.push(buf)
107                 raise RequestHandled
108         
109         def doError(self, reason = '', code = 100, headers = None):
110                 if headers is None: headers = {}
111                 headers.setdefault('Content-Type', 'text/plain')
112                 return self.sendReply(500, reason.encode('utf8'), headers)
113         
114         def doHeader_accept_encoding(self, value):
115                 if b'gzip' in value:
116                         self.quirks['gzip'] = True
117         
118         def checkAuthentication(self, *a, **ka):
119                 return self.server.checkAuthentication(*a, **ka)
120         
121         def doHeader_authorization(self, value):
122                 value = value.split(b' ')
123                 if len(value) != 2 or value[0] != b'Basic':
124                         return self.doError('Bad Authorization header')
125                 value = b64decode(value[1])
126                 (un, pw, *x) = value.split(b':', 1) + [None]
127                 valid = False
128                 try:
129                         valid = self.checkAuthentication(un, pw)
130                 except:
131                         return self.doError('Error checking authorization')
132                 if valid:
133                         self.Username = un.decode('utf8')
134         
135         def doHeader_connection(self, value):
136                 if value == b'close':
137                         self.quirks['close'] = None
138         
139         def doHeader_content_length(self, value):
140                 self.CL = int(value)
141         
142         def doHeader_x_forwarded_for(self, value):
143                 if self.addr[0] in self.server.TrustedForwarders:
144                         self.remoteHost = value.decode('ascii')
145                 else:
146                         self.logger.debug("Ignoring X-Forwarded-For header from %s" % (self.addr[0],))
147         
148         def doAuthenticate(self):
149                 self.sendReply(401, headers={'WWW-Authenticate': 'Basic realm="%s"' % (self.server.ServerName,)})
150         
151         def parse_headers(self, hs):
152                 self.CL = None
153                 self.Username = None
154                 self.method = None
155                 self.path = None
156                 hs = re.split(br'\r?\n', hs)
157                 data = hs.pop(0).split(b' ')
158                 try:
159                         self.method = data[0]
160                         self.path = data[1]
161                 except IndexError:
162                         self.close()
163                         return
164                 self.extensions = []
165                 self.reqinfo = {}
166                 self.quirks = dict(self.default_quirks)
167                 if data[2:] != [b'HTTP/1.1']:
168                         self.quirks['close'] = None
169                 while True:
170                         try:
171                                 data = hs.pop(0)
172                         except IndexError:
173                                 break
174                         data = tuple(map(lambda a: a.strip(), data.split(b':', 1)))
175                         method = 'doHeader_' + data[0].decode('ascii').lower()
176                         if hasattr(self, method):
177                                 try:
178                                         getattr(self, method)(data[1])
179                                 except RequestAlreadyHandled:
180                                         # Ignore multiple errors and such
181                                         pass
182         
183         def found_terminator(self):
184                 if self.reading_headers:
185                         inbuf = b"".join(self.incoming)
186                         self.incoming = []
187                         m = re.match(br'^[\r\n]+', inbuf)
188                         if m:
189                                 inbuf = inbuf[len(m.group(0)):]
190                         if not inbuf:
191                                 return
192                         
193                         self.reading_headers = False
194                         self.parse_headers(inbuf)
195                         if self.CL:
196                                 self.set_terminator(self.CL)
197                                 return
198                 
199                 self.set_terminator(None)
200                 try:
201                         self.handle_request()
202                         raise RequestNotHandled
203                 except RequestHandled:
204                         self.reset_request()
205                 except AsyncRequest:
206                         pass
207                 except:
208                         self.logger.error(traceback.format_exc())
209         
210         def handle_src_request(self):
211                 if _SourceFiles is None:
212                         return self.sendReply(404)
213                 # For AGPL compliance, allow direct downloads of source code
214                 p = self.path[5:]
215                 if p == b'':
216                         # List of files
217                         body = b'<html><head><title>Source Code</title></head><body>\t\n'
218                         body += b'\t<a href="tar">(tar archive of all files)</a><br><br>\n'
219                         for f in _SourceFiles:
220                                 body += b'\t<a href="' + f + b'">\n' + f + b'\n\t</a><br>\n'
221                         body += b'\t</body></html>\n'
222                         return self.sendReply(body=body, headers={'Content-Type':'text/html'})
223                 if p == b'tar':
224                         body = bytearray()
225                         dn = b'eloipool-' + _GitDesc + b'/'
226                         for f in _SourceFiles:
227                                 fs = f.decode('utf8')
228                                 fstat = os.lstat(fs)
229                                 islink = stat.S_ISLNK(fstat.st_mode)
230                                 if islink:
231                                         data = b''
232                                         link = os.readlink(f)
233                                 else:
234                                         with open("%s/%s" % (_srcdir, fs), 'rb') as ff:
235                                                 data = ff.read()
236                                         link = b''
237                                 h = bytearray()
238                                 f = dn + f
239                                 h += f + bytes(max(0, 100 - len(f)))
240                                 h += ('%07o' % (fstat.st_mode,)[-7:]).encode('utf8') + b'\0'
241                                 h += bytes(16)
242                                 h += ('%012o%012o' % (fstat.st_size, fstat.st_mtime)).encode('utf8')
243                                 h += b'        '  # chksum
244                                 h += b'2' if islink else b'0'
245                                 h += link + bytes(max(0, 355 - len(link)))
246                                 h[148:156] = ('%07o' % (sum(h),)).encode('utf8') + b'\0'
247                                 body += h + data + bytes(512 - ((fstat.st_size % 512) or 512))
248                         self.sendReply(body=body, headers={'Content-Type':'application/x-tar'})
249                 if p not in _SourceFiles:
250                         return self.sendReply(404)
251                 ct = 'text/plain'
252                 if p[-3:] == b'.py': ct = 'application/x-python'
253                 elif p[-11:] == b'.py.example': ct = 'application/x-python'
254                 p = p.decode('utf8')
255                 with open("%s/%s" % (_srcdir, p), 'rb') as f:
256                         self.sendReply(body=f.read(), headers={'Content-Type':ct})
257         
258         def reset_request(self):
259                 self.replySent = False
260                 self.incoming = []
261                 self.set_terminator( (b"\n\n", b"\r\n\r\n") )
262                 self.reading_headers = True
263                 self.changeTask(self.handle_timeout, time() + 150)
264                 if 'close' in self.quirks:
265                         self.close()
266                 # proxies can do multiple requests in one connection for multiple clients, so reset address every time
267                 self.remoteHost = self.addr[0]
268         
269         def __init__(self, *a, **ka):
270                 super().__init__(*a, **ka)
271                 self.quirks = dict(self.default_quirks)
272                 self.reset_request()
273         
274 setattr(HTTPHandler, 'doHeader_accept-encoding', HTTPHandler.doHeader_accept_encoding);
275 setattr(HTTPHandler, 'doHeader_content-length', HTTPHandler.doHeader_content_length);
276 setattr(HTTPHandler, 'doHeader_x-forwarded-for', HTTPHandler.doHeader_x_forwarded_for);