Code

safer logging from HTTP server (sf bug 896917
[roundup.git] / roundup / scripts / roundup_server.py
1 # Copyright (c) 2001 Bizar Software Pty Ltd (http://www.bizarsoftware.com.au/)
2 # This module is free software, and you may redistribute it and/or modify
3 # under the same terms as Python, so long as this copyright message and
4 # disclaimer are retained in their original form.
5 #
6 # IN NO EVENT SHALL BIZAR SOFTWARE PTY LTD BE LIABLE TO ANY PARTY FOR
7 # DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES ARISING
8 # OUT OF THE USE OF THIS CODE, EVEN IF THE AUTHOR HAS BEEN ADVISED OF THE
9 # POSSIBILITY OF SUCH DAMAGE.
10 #
11 # BIZAR SOFTWARE PTY LTD SPECIFICALLY DISCLAIMS ANY WARRANTIES, INCLUDING,
12 # BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
13 # FOR A PARTICULAR PURPOSE.  THE CODE PROVIDED HEREUNDER IS ON AN "AS IS"
14 # BASIS, AND THERE IS NO OBLIGATION WHATSOEVER TO PROVIDE MAINTENANCE,
15 # SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS.
16
18 """Command-line script that runs a server over roundup.cgi.client.
20 $Id: roundup_server.py,v 1.38 2004-02-15 21:44:02 richard Exp $
21 """
22 __docformat__ = 'restructuredtext'
24 # python version check
25 from roundup import version_check
27 import sys, os, urllib, StringIO, traceback, cgi, binascii, getopt, imp
28 import BaseHTTPServer, socket, errno
30 # Roundup modules of use here
31 from roundup.cgi import cgitb, client
32 import roundup.instance
33 from roundup.i18n import _
35 #
36 ##  Configuration
37 #
39 # This indicates where the Roundup trackers live. They're given as NAME ->
40 # TRACKER_HOME, where the NAME part is used in the URL to select the
41 # appropriate reacker.
42 # Make sure the NAME part doesn't include any url-unsafe characters like 
43 # spaces, as these confuse the cookie handling in browsers like IE.
44 TRACKER_HOMES = {
45 #    'example': '/path/to/example',
46 }
48 ROUNDUP_USER = None
49 ROUNDUP_GROUP = None
50 ROUNDUP_LOG_IP = 1
51 HOSTNAME = ''
52 PORT = 8080
53 PIDFILE = None
54 LOGFILE = None
57 #
58 ##  end configuration
59 #
61 import zlib, base64
62 favico = zlib.decompress(base64.decodestring('''
63 eJyVUk2IQVEUfn4yaRYjibdQZiVba/ZE2djIUmHWFjaKGVmIlY2iFMVG2ViQhXqFSP6iFFJvw4uF
64 LGdWd743mpeMn+a88917Oue7955z3qEoET6FQkHx8iahKDV2A8B7XgERRf/EKMSUzyf8ypbbnnQy
65 mWBdr9eVSkVw3tJGoxGNRpvNZigUyufzWPv9Pvwcx0UiERj7/V4g73Y7j8fTarWMRmO73U4kEkKI
66 YZhardbr9eLxuOD0+/2ZTMZisYjFYpqmU6kU799uN5tNMBg8HA7ZbPY8GaTh8/mEipRKpclk0ul0
67 NpvNarUmk0mWZS/yr9frcrmc+iMOh+NWydPp1Ov1SiSSc344HL7fKKfTiSN2u12tVqOcxWJxn6/V
68 ag0GAwxkrlKp5vP5fT7ulMlk6XRar9dLpVIUXi6Xb5Hxa1wul0ajKZVKsVjM7XYXCoVOp3OVPJvN
69 AoFAtVo1m825XO7hSODOYrH4kHbxxGAwwODBGI/H6DBs5LNara7yl8slGjIcDsHpdrunU6PRCAP2
70 r3fPdUcIYeyEfLSAJ0LeAUZHCAt8Al/8/kLIEWDB5YDj0wm8fAP6fVfo
71 '''.strip()))
73 class RoundupHTTPServer(SafeLogging, BaseHTTPServer.HTTPServer):
74     def log_message(self, format, *args):
75         ''' Try to use the logging package, otherwise *safely* log to
76             stderr.
77         '''
78         try:
79             BaseHTTPServer.HTTPServer.log_message(self, format, *args)
80         except IOError:
81             # stderr is no longer viable, we can't log
82             pass
84 class RoundupRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
85     TRACKER_HOMES = TRACKER_HOMES
86     ROUNDUP_USER = ROUNDUP_USER
88     def run_cgi(self):
89         """ Execute the CGI command. Wrap an innner call in an error
90             handler so all errors can be caught.
91         """
92         save_stdin = sys.stdin
93         sys.stdin = self.rfile
94         try:
95             self.inner_run_cgi()
96         except client.NotFound:
97             self.send_error(404, self.path)
98         except client.Unauthorised:
99             self.send_error(403, self.path)
100         except:
101             exc, val, tb = sys.exc_info()
102             if hasattr(socket, 'timeout') and exc == socket.timeout:
103                 s = StringIO.StringIO()
104                 traceback.print_exc(None, s)
105                 self.log_message(str(s.getvalue()))
106             else:
107                 # it'd be nice to be able to detect if these are going to have
108                 # any effect...
109                 self.send_response(400)
110                 self.send_header('Content-Type', 'text/html')
111                 self.end_headers()
112                 try:
113                     reload(cgitb)
114                     self.wfile.write(cgitb.breaker())
115                     self.wfile.write(cgitb.html())
116                 except:
117                     s = StringIO.StringIO()
118                     traceback.print_exc(None, s)
119                     self.wfile.write("<pre>")
120                     self.wfile.write(cgi.escape(s.getvalue()))
121                     self.wfile.write("</pre>\n")
122         sys.stdin = save_stdin
124     do_GET = do_POST = run_cgi
126     def index(self):
127         ''' Print up an index of the available trackers
128         '''
129         self.send_response(200)
130         self.send_header('Content-Type', 'text/html')
131         self.end_headers()
132         w = self.wfile.write
133         w(_('<html><head><title>Roundup trackers index</title></head>\n'))
134         w(_('<body><h1>Roundup trackers index</h1><ol>\n'))
135         keys = self.TRACKER_HOMES.keys()
136         keys.sort()
137         for tracker in keys:
138             w(_('<li><a href="%(tracker_url)s/index">%(tracker_name)s</a>\n')%{
139                 'tracker_url': urllib.quote(tracker),
140                 'tracker_name': cgi.escape(tracker)})
141         w(_('</ol></body></html>'))
143     def inner_run_cgi(self):
144         ''' This is the inner part of the CGI handling
145         '''
146         rest = self.path
148         if rest == '/favicon.ico':
149             raise client.NotFound
151         i = rest.rfind('?')
152         if i >= 0:
153             rest, query = rest[:i], rest[i+1:]
154         else:
155             query = ''
157         # no tracker - spit out the index
158         if rest == '/':
159             return self.index()
161         # figure the tracker
162         l_path = rest.split('/')
163         tracker_name = urllib.unquote(l_path[1])
165         # handle missing trailing '/'
166         if len(l_path) == 2:
167             self.send_response(301)
168             # redirect - XXX https??
169             protocol = 'http'
170             url = '%s://%s%s/'%(protocol, self.headers['host'], self.path)
171             self.send_header('Location', url)
172             self.end_headers()
173             self.wfile.write('Moved Permanently')
174             return
176         if self.TRACKER_HOMES.has_key(tracker_name):
177             tracker_home = self.TRACKER_HOMES[tracker_name]
178             tracker = roundup.instance.open(tracker_home)
179         else:
180             raise client.NotFound
182         # figure out what the rest of the path is
183         if len(l_path) > 2:
184             rest = '/'.join(l_path[2:])
185         else:
186             rest = '/'
188         # Set up the CGI environment
189         env = {}
190         env['TRACKER_NAME'] = tracker_name
191         env['REQUEST_METHOD'] = self.command
192         env['PATH_INFO'] = urllib.unquote(rest)
193         if query:
194             env['QUERY_STRING'] = query
195         host = self.address_string()
196         if self.headers.typeheader is None:
197             env['CONTENT_TYPE'] = self.headers.type
198         else:
199             env['CONTENT_TYPE'] = self.headers.typeheader
200         length = self.headers.getheader('content-length')
201         if length:
202             env['CONTENT_LENGTH'] = length
203         co = filter(None, self.headers.getheaders('cookie'))
204         if co:
205             env['HTTP_COOKIE'] = ', '.join(co)
206         env['HTTP_AUTHORIZATION'] = self.headers.getheader('authorization')
207         env['SCRIPT_NAME'] = ''
208         env['SERVER_NAME'] = self.server.server_name
209         env['SERVER_PORT'] = str(self.server.server_port)
210         env['HTTP_HOST'] = self.headers['host']
212         decoded_query = query.replace('+', ' ')
214         # do the roundup thang
215         c = tracker.Client(tracker, self, env)
216         c.main()
218     LOG_IPADDRESS = ROUNDUP_LOG_IP
219     def address_string(self):
220         if self.LOG_IPADDRESS:
221             return self.client_address[0]
222         else:
223             host, port = self.client_address
224             return socket.getfqdn(host)
226 def error():
227     exc_type, exc_value = sys.exc_info()[:2]
228     return _('Error: %s: %s' % (exc_type, exc_value))
230 try:
231     import win32serviceutil
232 except:
233     RoundupService = None
234 else:
235     # allow the win32
236     import win32service
237     import win32event
238     from win32event import *
239     from win32file import *
241     SvcShutdown = "ServiceShutdown"
243     class RoundupService(win32serviceutil.ServiceFramework, RoundupHTTPServer):
244         ''' A Roundup standalone server for Win32 by Ewout Prangsma
245         '''
246         _svc_name_ = "Roundup Bug Tracker"
247         _svc_display_name_ = "Roundup Bug Tracker"
248         address = (HOSTNAME, PORT)
249         def __init__(self, args):
250             # redirect stdout/stderr to our logfile
251             if LOGFILE:
252                 # appending, unbuffered
253                 sys.stdout = sys.stderr = open(LOGFILE, 'a', 0)
254             win32serviceutil.ServiceFramework.__init__(self, args)
255             RoundupHTTPServer.__init__(self, self.address, 
256                 RoundupRequestHandler)
258             # Create the necessary NT Event synchronization objects...
259             # hevSvcStop is signaled when the SCM sends us a notification
260             # to shutdown the service.
261             self.hevSvcStop = win32event.CreateEvent(None, 0, 0, None)
263             # hevConn is signaled when we have a new incomming connection.
264             self.hevConn    = win32event.CreateEvent(None, 0, 0, None)
266             # Hang onto this module for other people to use for logging
267             # purposes.
268             import servicemanager
269             self.servicemanager = servicemanager
271         def SvcStop(self):
272             # Before we do anything, tell the SCM we are starting the
273             # stop process.
274             self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
275             win32event.SetEvent(self.hevSvcStop)
277         def SvcDoRun(self):
278             try:
279                 self.serve_forever()
280             except SvcShutdown:
281                 pass
283         def get_request(self):
284             # Call WSAEventSelect to enable self.socket to be waited on.
285             WSAEventSelect(self.socket, self.hevConn, FD_ACCEPT)
286             while 1:
287                 try:
288                     rv = self.socket.accept()
289                 except socket.error, why:
290                     if why[0] != WSAEWOULDBLOCK:
291                         raise
292                     # Use WaitForMultipleObjects instead of select() because
293                     # on NT select() is only good for sockets, and not general
294                     # NT synchronization objects.
295                     rc = WaitForMultipleObjects((self.hevSvcStop, self.hevConn),
296                         0, INFINITE)
297                     if rc == WAIT_OBJECT_0:
298                         # self.hevSvcStop was signaled, this means:
299                         # Stop the service!
300                         # So we throw the shutdown exception, which gets
301                         # caught by self.SvcDoRun
302                         raise SvcShutdown
303                     # Otherwise, rc == WAIT_OBJECT_0 + 1 which means
304                     # self.hevConn was signaled, which means when we call 
305                     # self.socket.accept(), we'll have our incoming connection
306                     # socket!
307                     # Loop back to the top, and let that accept do its thing...
308                 else:
309                     # yay! we have a connection
310                     # However... the new socket is non-blocking, we need to
311                     # set it back into blocking mode. (The socket that accept()
312                     # returns has the same properties as the listening sockets,
313                     # this includes any properties set by WSAAsyncSelect, or 
314                     # WSAEventSelect, and whether its a blocking socket or not.)
315                     #
316                     # So if you yank the following line, the setblocking() call 
317                     # will be useless. The socket will still be in non-blocking
318                     # mode.
319                     WSAEventSelect(rv[0], self.hevConn, 0)
320                     rv[0].setblocking(1)
321                     break
322             return rv
324 def usage(message=''):
325     if RoundupService:
326         win = ''' -c: Windows Service options.  If you want to run the server as a Windows
327      Service, you must configure the rest of the options by changing the
328      constants of this program.  You will at least configure one tracker
329      in the TRACKER_HOMES variable.  This option is mutually exclusive
330      from the rest.  Typing "roundup-server -c help" shows Windows
331      Services specifics.'''
332     else:
333         win = ''
334     port=PORT
335     print _('''%(message)s
336 Usage:
337 roundup-server [options] [name=tracker home]*
339 options:
340  -n: sets the host name
341  -p: sets the port to listen on (default: %(port)s)
342  -u: sets the uid to this user after listening on the port
343  -g: sets the gid to this group after listening on the port
344  -l: sets a filename to log to (instead of stdout)
345  -d: run the server in the background and on UN*X write the server's PID
346      to the nominated file. The -l option *must* be specified if this
347      option is.
348  -N: log client machine names in access log instead of IP addresses (much
349      slower)
350 %(win)s
352 name=tracker home:
353    Sets the tracker home(s) to use. The name is how the tracker is
354    identified in the URL (it's the first part of the URL path). The
355    tracker home is the directory that was identified when you did
356    "roundup-admin init". You may specify any number of these name=home
357    pairs on the command-line. For convenience, you may edit the
358    TRACKER_HOMES variable in the roundup-server file instead.
359    Make sure the name part doesn't include any url-unsafe characters like 
360    spaces, as these confuse the cookie handling in browsers like IE.
361 ''')%locals()
362     sys.exit(0)
364 def daemonize(pidfile):
365     ''' Turn this process into a daemon.
366         - make sure the sys.std(in|out|err) are completely cut off
367         - make our parent PID 1
369         Write our new PID to the pidfile.
371         From A.M. Kuuchling (possibly originally Greg Ward) with
372         modification from Oren Tirosh, and finally a small mod from me.
373     '''
374     # Fork once
375     if os.fork() != 0:
376         os._exit(0)
378     # Create new session
379     os.setsid()
381     # Second fork to force PPID=1
382     pid = os.fork()
383     if pid:
384         pidfile = open(pidfile, 'w')
385         pidfile.write(str(pid))
386         pidfile.close()
387         os._exit(0)         
389     os.chdir("/")         
390     os.umask(0)
392     # close off sys.std(in|out|err), redirect to devnull so the file
393     # descriptors can't be used again
394     devnull = os.open('/dev/null', 0)
395     os.dup2(devnull, 0)
396     os.dup2(devnull, 1)
397     os.dup2(devnull, 2)
399 def run(port=PORT, success_message=None):
400     ''' Script entry point - handle args and figure out what to to.
401     '''
402     # time out after a minute if we can
403     import socket
404     if hasattr(socket, 'setdefaulttimeout'):
405         socket.setdefaulttimeout(60)
407     hostname = HOSTNAME
408     pidfile = PIDFILE
409     logfile = LOGFILE
410     user = ROUNDUP_USER
411     group = ROUNDUP_GROUP
412     svc_args = None
414     try:
415         # handle the command-line args
416         options = 'n:p:u:d:l:hN'
417         if RoundupService:
418             options += 'c'
420         try:
421             optlist, args = getopt.getopt(sys.argv[1:], options)
422         except getopt.GetoptError, e:
423             usage(str(e))
425         user = ROUNDUP_USER
426         group = None
427         for (opt, arg) in optlist:
428             if opt == '-n': hostname = arg
429             elif opt == '-p': port = int(arg)
430             elif opt == '-u': user = arg
431             elif opt == '-g': group = arg
432             elif opt == '-d': pidfile = os.path.abspath(arg)
433             elif opt == '-l': logfile = os.path.abspath(arg)
434             elif opt == '-h': usage()
435             elif opt == '-N': RoundupRequestHandler.LOG_IPADDRESS = 0
436             elif opt == '-c': svc_args = [opt] + args; args = None
438         if svc_args is not None and len(optlist) > 1:
439             raise ValueError, _("windows service option must be the only one")
441         if pidfile and not logfile:
442             raise ValueError, _("logfile *must* be specified if pidfile is")
443   
444         # obtain server before changing user id - allows to use port <
445         # 1024 if started as root
446         address = (hostname, port)
447         try:
448             httpd = RoundupHTTPServer(address, RoundupRequestHandler)
449         except socket.error, e:
450             if e[0] == errno.EADDRINUSE:
451                 raise socket.error, \
452                       _("Unable to bind to port %s, port already in use." % port)
453             raise
455         if group is not None and hasattr(os, 'getgid'):
456             # if root, setgid to the running user
457             if not os.getgid() and user is not None:
458                 try:
459                     import pwd
460                 except ImportError:
461                     raise ValueError, _("Can't change groups - no pwd module")
462                 try:
463                     gid = pwd.getpwnam(user)[3]
464                 except KeyError:
465                     raise ValueError,_("Group %(group)s doesn't exist")%locals()
466                 os.setgid(gid)
467             elif os.getgid() and user is not None:
468                 print _('WARNING: ignoring "-g" argument, not root')
470         if hasattr(os, 'getuid'):
471             # if root, setuid to the running user
472             if not os.getuid() and user is not None:
473                 try:
474                     import pwd
475                 except ImportError:
476                     raise ValueError, _("Can't change users - no pwd module")
477                 try:
478                     uid = pwd.getpwnam(user)[2]
479                 except KeyError:
480                     raise ValueError, _("User %(user)s doesn't exist")%locals()
481                 os.setuid(uid)
482             elif os.getuid() and user is not None:
483                 print _('WARNING: ignoring "-u" argument, not root')
485             # People can remove this check if they're really determined
486             if not os.getuid() and user is None:
487                 raise ValueError, _("Can't run as root!")
489         # handle tracker specs
490         if args:
491             d = {}
492             for arg in args:
493                 try:
494                     name, home = arg.split('=')
495                 except ValueError:
496                     raise ValueError, _("Instances must be name=home")
497                 d[name] = os.path.abspath(home)
498             RoundupRequestHandler.TRACKER_HOMES = d
499     except SystemExit:
500         raise
501     except ValueError:
502         usage(error())
503     except:
504         print error()
505         sys.exit(1)
507     # we don't want the cgi module interpreting the command-line args ;)
508     sys.argv = sys.argv[:1]
510     if pidfile:
511         if not hasattr(os, 'fork'):
512             print "Sorry, you can't run the server as a daemon on this" \
513                 'Operating System'
514             sys.exit(0)
515         else:
516             daemonize(pidfile)
518     if svc_args is not None:
519         # don't do any other stuff
520         return win32serviceutil.HandleCommandLine(RoundupService, argv=svc_args)
522     # redirect stdout/stderr to our logfile
523     if logfile:
524         # appending, unbuffered
525         sys.stdout = sys.stderr = open(logfile, 'a', 0)
527     if success_message:
528         print success_message
529     else:
530         print _('Roundup server started on %(address)s')%locals()
532     try:
533         httpd.serve_forever()
534     except KeyboardInterrupt:
535         print 'Keyboard Interrupt: exiting'
537 if __name__ == '__main__':
538     run()
540 # vim: set filetype=python ts=4 sw=4 et si