Code

- added a favicon
[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.41 2004-04-05 00:51:45 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 try:
36     import signal
37 except:
38     signal = None
40 #
41 ##  Configuration
42 #
44 # This indicates where the Roundup trackers live. They're given as NAME ->
45 # TRACKER_HOME, where the NAME part is used in the URL to select the
46 # appropriate reacker.
47 # Make sure the NAME part doesn't include any url-unsafe characters like 
48 # spaces, as these confuse the cookie handling in browsers like IE.
49 TRACKER_HOMES = {
50 #    'example': '/path/to/example',
51 }
53 ROUNDUP_USER = None
54 ROUNDUP_GROUP = None
55 ROUNDUP_LOG_IP = 1
56 HOSTNAME = ''
57 PORT = 8080
58 PIDFILE = None
59 LOGFILE = None
62 #
63 ##  end configuration
64 #
66 import zlib, base64
67 favico = zlib.decompress(base64.decodestring('''
68 eJztjr1PmlEUh59XgVoshdYPWorFIhaRFq0t9pNq37b60lYSTRzcTFw6GAfj5gDYaF0dTB0MxMSE
69 gQQd3FzKJiEC0UCIUUN1M41pV2JCXySg/0ITn5tfzvmdc+85FwT56HSc81UJjXJsk1UsNcsSqCk1
70 BS64lK+vr7OyssLJyQl2ux2j0cjU1BQajYZIJEIwGMRms+H3+zEYDExOTjI2Nsbm5iZWqxWv18vW
71 1hZDQ0Ok02kmJiY4Ojpienqa3d1dxsfHUSqVeDwe5ufnyeVyrK6u4nK5ODs7Y3FxEYfDwdzcHCaT
72 icPDQ5LJJIIgMDIyQj6fZ39/n+3tbdbW1pAkiYWFBWZmZtjb2yMejzM8PEwgEMDn85HNZonFYqjV
73 asLhMMvLy2QyGfR6PaOjowwODmKxWDg+PkalUhEKhSgUCiwtLWE2m9nZ2UGhULCxscHp6SmpVIpo
74 NMrs7CwHBwdotVoSiQRXXPG/IzY7RHtt922xjFRb01H1XhKfPBNbi/7my7rrLXJ88eppvxwEfV3f
75 NY3Y6exofVdsV3+2wnPFDdPjB83n7xuVpcFvygPbGwxF31LZIKrQDfR2Xvh7lmrX654L/7bvlnng
76 bn3Zuj8M9Hepux6VfZtW1yA6K7cfGqVu8TL325u+fHTb71QKbk+7TZQ+lTc6RcnpqW8qmVQBoj/g
77 23eo0sr/NIGvB37K+lOWXMvJ+uWFeKGU/03Cb7n3D4M3wxI=
78 '''.strip()))
80 class RoundupRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
81     TRACKER_HOMES = TRACKER_HOMES
82     ROUNDUP_USER = ROUNDUP_USER
84     def run_cgi(self):
85         """ Execute the CGI command. Wrap an innner call in an error
86             handler so all errors can be caught.
87         """
88         save_stdin = sys.stdin
89         sys.stdin = self.rfile
90         try:
91             self.inner_run_cgi()
92         except client.NotFound:
93             self.send_error(404, self.path)
94         except client.Unauthorised:
95             self.send_error(403, self.path)
96         except:
97             exc, val, tb = sys.exc_info()
98             if hasattr(socket, 'timeout') and exc == socket.timeout:
99                 s = StringIO.StringIO()
100                 traceback.print_exc(None, s)
101                 self.log_message(str(s.getvalue()))
102             else:
103                 # it'd be nice to be able to detect if these are going to have
104                 # any effect...
105                 self.send_response(400)
106                 self.send_header('Content-Type', 'text/html')
107                 self.end_headers()
108                 try:
109                     reload(cgitb)
110                     self.wfile.write(cgitb.breaker())
111                     self.wfile.write(cgitb.html())
112                 except:
113                     s = StringIO.StringIO()
114                     traceback.print_exc(None, s)
115                     self.wfile.write("<pre>")
116                     self.wfile.write(cgi.escape(s.getvalue()))
117                     self.wfile.write("</pre>\n")
118         sys.stdin = save_stdin
120     do_GET = do_POST = run_cgi
122     def index(self):
123         ''' Print up an index of the available trackers
124         '''
125         self.send_response(200)
126         self.send_header('Content-Type', 'text/html')
127         self.end_headers()
128         w = self.wfile.write
129         w(_('<html><head><title>Roundup trackers index</title></head>\n'))
130         w(_('<body><h1>Roundup trackers index</h1><ol>\n'))
131         keys = self.TRACKER_HOMES.keys()
132         keys.sort()
133         for tracker in keys:
134             w(_('<li><a href="%(tracker_url)s/index">%(tracker_name)s</a>\n')%{
135                 'tracker_url': urllib.quote(tracker),
136                 'tracker_name': cgi.escape(tracker)})
137         w(_('</ol></body></html>'))
139     def inner_run_cgi(self):
140         ''' This is the inner part of the CGI handling
141         '''
142         rest = self.path
144         if rest == '/favicon.ico':
145             self.send_response(200)
146             self.send_header('Content-Type', 'image/x-icon')
147             self.end_headers()
148             self.wfile.write(favico)
149             return
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 log_message(self, format, *args):
227         ''' Try to *safely* log to stderr.
228         '''
229         try:
230             BaseHTTPServer.BaseHTTPRequestHandler.log_message(self,
231                 format, *args)
232         except IOError:
233             # stderr is no longer viable
234             pass
236 def error():
237     exc_type, exc_value = sys.exc_info()[:2]
238     return _('Error: %s: %s' % (exc_type, exc_value))
240 try:
241     import win32serviceutil
242 except:
243     RoundupService = None
244 else:
245     # allow the win32
246     import win32service
247     import win32event
248     from win32event import *
249     from win32file import *
251     SvcShutdown = "ServiceShutdown"
253     class RoundupService(win32serviceutil.ServiceFramework,
254             BaseHTTPServer.HTTPServer):
255         ''' A Roundup standalone server for Win32 by Ewout Prangsma
256         '''
257         _svc_name_ = "Roundup Bug Tracker"
258         _svc_display_name_ = "Roundup Bug Tracker"
259         address = (HOSTNAME, PORT)
260         def __init__(self, args):
261             # redirect stdout/stderr to our logfile
262             if LOGFILE:
263                 # appending, unbuffered
264                 sys.stdout = sys.stderr = open(LOGFILE, 'a', 0)
265             win32serviceutil.ServiceFramework.__init__(self, args)
266             BaseHTTPServer.HTTPServer.__init__(self, self.address, 
267                 RoundupRequestHandler)
269             # Create the necessary NT Event synchronization objects...
270             # hevSvcStop is signaled when the SCM sends us a notification
271             # to shutdown the service.
272             self.hevSvcStop = win32event.CreateEvent(None, 0, 0, None)
274             # hevConn is signaled when we have a new incomming connection.
275             self.hevConn    = win32event.CreateEvent(None, 0, 0, None)
277             # Hang onto this module for other people to use for logging
278             # purposes.
279             import servicemanager
280             self.servicemanager = servicemanager
282         def SvcStop(self):
283             # Before we do anything, tell the SCM we are starting the
284             # stop process.
285             self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
286             win32event.SetEvent(self.hevSvcStop)
288         def SvcDoRun(self):
289             try:
290                 self.serve_forever()
291             except SvcShutdown:
292                 pass
294         def get_request(self):
295             # Call WSAEventSelect to enable self.socket to be waited on.
296             WSAEventSelect(self.socket, self.hevConn, FD_ACCEPT)
297             while 1:
298                 try:
299                     rv = self.socket.accept()
300                 except socket.error, why:
301                     if why[0] != WSAEWOULDBLOCK:
302                         raise
303                     # Use WaitForMultipleObjects instead of select() because
304                     # on NT select() is only good for sockets, and not general
305                     # NT synchronization objects.
306                     rc = WaitForMultipleObjects((self.hevSvcStop, self.hevConn),
307                         0, INFINITE)
308                     if rc == WAIT_OBJECT_0:
309                         # self.hevSvcStop was signaled, this means:
310                         # Stop the service!
311                         # So we throw the shutdown exception, which gets
312                         # caught by self.SvcDoRun
313                         raise SvcShutdown
314                     # Otherwise, rc == WAIT_OBJECT_0 + 1 which means
315                     # self.hevConn was signaled, which means when we call 
316                     # self.socket.accept(), we'll have our incoming connection
317                     # socket!
318                     # Loop back to the top, and let that accept do its thing...
319                 else:
320                     # yay! we have a connection
321                     # However... the new socket is non-blocking, we need to
322                     # set it back into blocking mode. (The socket that accept()
323                     # returns has the same properties as the listening sockets,
324                     # this includes any properties set by WSAAsyncSelect, or 
325                     # WSAEventSelect, and whether its a blocking socket or not.)
326                     #
327                     # So if you yank the following line, the setblocking() call 
328                     # will be useless. The socket will still be in non-blocking
329                     # mode.
330                     WSAEventSelect(rv[0], self.hevConn, 0)
331                     rv[0].setblocking(1)
332                     break
333             return rv
335 def usage(message=''):
336     if RoundupService:
337         win = ''' -c: Windows Service options.  If you want to run the server as a Windows
338      Service, you must configure the rest of the options by changing the
339      constants of this program.  You will at least configure one tracker
340      in the TRACKER_HOMES variable.  This option is mutually exclusive
341      from the rest.  Typing "roundup-server -c help" shows Windows
342      Services specifics.'''
343     else:
344         win = ''
345     port=PORT
346     print _('''%(message)s
347 Usage:
348 roundup-server [options] [name=tracker home]*
350 options:
351  -n: sets the host name
352  -p: sets the port to listen on (default: %(port)s)
353  -u: sets the uid to this user after listening on the port
354  -g: sets the gid to this group after listening on the port
355  -l: sets a filename to log to (instead of stderr / stdout)
356  -d: run the server in the background and on UN*X write the server's PID
357      to the nominated file. The -l option *must* be specified if this
358      option is.
359  -N: log client machine names in access log instead of IP addresses (much
360      slower)
361 %(win)s
363 name=tracker home:
364    Sets the tracker home(s) to use. The name is how the tracker is
365    identified in the URL (it's the first part of the URL path). The
366    tracker home is the directory that was identified when you did
367    "roundup-admin init". You may specify any number of these name=home
368    pairs on the command-line. For convenience, you may edit the
369    TRACKER_HOMES variable in the roundup-server file instead.
370    Make sure the name part doesn't include any url-unsafe characters like 
371    spaces, as these confuse the cookie handling in browsers like IE.
372 ''')%locals()
373     sys.exit(0)
375 def daemonize(pidfile):
376     ''' Turn this process into a daemon.
377         - make sure the sys.std(in|out|err) are completely cut off
378         - make our parent PID 1
380         Write our new PID to the pidfile.
382         From A.M. Kuuchling (possibly originally Greg Ward) with
383         modification from Oren Tirosh, and finally a small mod from me.
384     '''
385     # Fork once
386     if os.fork() != 0:
387         os._exit(0)
389     # Create new session
390     os.setsid()
392     # Second fork to force PPID=1
393     pid = os.fork()
394     if pid:
395         pidfile = open(pidfile, 'w')
396         pidfile.write(str(pid))
397         pidfile.close()
398         os._exit(0)         
400     os.chdir("/")         
401     os.umask(0)
403     # close off sys.std(in|out|err), redirect to devnull so the file
404     # descriptors can't be used again
405     devnull = os.open('/dev/null', 0)
406     os.dup2(devnull, 0)
407     os.dup2(devnull, 1)
408     os.dup2(devnull, 2)
410 def run(port=PORT, success_message=None):
411     ''' Script entry point - handle args and figure out what to to.
412     '''
413     # time out after a minute if we can
414     import socket
415     if hasattr(socket, 'setdefaulttimeout'):
416         socket.setdefaulttimeout(60)
418     hostname = HOSTNAME
419     pidfile = PIDFILE
420     logfile = LOGFILE
421     user = ROUNDUP_USER
422     group = ROUNDUP_GROUP
423     svc_args = None
425     try:
426         # handle the command-line args
427         options = 'n:p:u:d:l:hN'
428         if RoundupService:
429             options += 'c'
431         try:
432             optlist, args = getopt.getopt(sys.argv[1:], options)
433         except getopt.GetoptError, e:
434             usage(str(e))
436         user = ROUNDUP_USER
437         group = None
438         for (opt, arg) in optlist:
439             if opt == '-n': hostname = arg
440             elif opt == '-p': port = int(arg)
441             elif opt == '-u': user = arg
442             elif opt == '-g': group = arg
443             elif opt == '-d': pidfile = os.path.abspath(arg)
444             elif opt == '-l': logfile = os.path.abspath(arg)
445             elif opt == '-h': usage()
446             elif opt == '-N': RoundupRequestHandler.LOG_IPADDRESS = 0
447             elif opt == '-c': svc_args = [opt] + args; args = None
449         if svc_args is not None and len(optlist) > 1:
450             raise ValueError, _("windows service option must be the only one")
452         if pidfile and not logfile:
453             raise ValueError, _("logfile *must* be specified if pidfile is")
454   
455         # obtain server before changing user id - allows to use port <
456         # 1024 if started as root
457         address = (hostname, port)
458         try:
459             httpd = BaseHTTPServer.HTTPServer(address, RoundupRequestHandler)
460         except socket.error, e:
461             if e[0] == errno.EADDRINUSE:
462                 raise socket.error, \
463                       _("Unable to bind to port %s, port already in use." % port)
464             raise
466         if group is not None and hasattr(os, 'getgid'):
467             # if root, setgid to the running user
468             if not os.getgid() and user is not None:
469                 try:
470                     import pwd
471                 except ImportError:
472                     raise ValueError, _("Can't change groups - no pwd module")
473                 try:
474                     gid = pwd.getpwnam(user)[3]
475                 except KeyError:
476                     raise ValueError,_("Group %(group)s doesn't exist")%locals()
477                 os.setgid(gid)
478             elif os.getgid() and user is not None:
479                 print _('WARNING: ignoring "-g" argument, not root')
481         if hasattr(os, 'getuid'):
482             # if root, setuid to the running user
483             if not os.getuid() and user is not None:
484                 try:
485                     import pwd
486                 except ImportError:
487                     raise ValueError, _("Can't change users - no pwd module")
488                 try:
489                     uid = pwd.getpwnam(user)[2]
490                 except KeyError:
491                     raise ValueError, _("User %(user)s doesn't exist")%locals()
492                 os.setuid(uid)
493             elif os.getuid() and user is not None:
494                 print _('WARNING: ignoring "-u" argument, not root')
496             # People can remove this check if they're really determined
497             if not os.getuid() and user is None:
498                 raise ValueError, _("Can't run as root!")
500         # handle tracker specs
501         if args:
502             d = {}
503             for arg in args:
504                 try:
505                     name, home = arg.split('=')
506                 except ValueError:
507                     raise ValueError, _("Instances must be name=home")
508                 d[name] = os.path.abspath(home)
509             RoundupRequestHandler.TRACKER_HOMES = d
510     except SystemExit:
511         raise
512     except ValueError:
513         usage(error())
514     except:
515         print error()
516         sys.exit(1)
518     # we don't want the cgi module interpreting the command-line args ;)
519     sys.argv = sys.argv[:1]
521     if pidfile:
522         if not hasattr(os, 'fork'):
523             print "Sorry, you can't run the server as a daemon on this" \
524                 'Operating System'
525             sys.exit(0)
526         else:
527             daemonize(pidfile)
529     if svc_args is not None:
530         # don't do any other stuff
531         return win32serviceutil.HandleCommandLine(RoundupService, argv=svc_args)
533     # redirect stdout/stderr to our logfile
534     if logfile:
535         # appending, unbuffered
536         sys.stdout = sys.stderr = open(logfile, 'a', 0)
538     if success_message:
539         print success_message
540     else:
541         print _('Roundup server started on %(address)s')%locals()
543     try:
544         httpd.serve_forever()
545     except KeyboardInterrupt:
546         print 'Keyboard Interrupt: exiting'
548 if __name__ == '__main__':
549     run()
551 # vim: set filetype=python ts=4 sw=4 et si