Code

sqlite backend uses the global lock again
[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.44 2004-04-07 01:12:26 richard Exp $
21 """
22 __docformat__ = 'restructuredtext'
24 # python version check
25 from roundup import version_check
26 from roundup import __version__ as roundup_version
28 import sys, os, urllib, StringIO, traceback, cgi, binascii, getopt, imp
29 import SocketServer, BaseHTTPServer, socket, errno
31 # Roundup modules of use here
32 from roundup.cgi import cgitb, client
33 import roundup.instance
34 from roundup.i18n import _
36 try:
37     import signal
38 except:
39     signal = None
41 #
42 ##  Configuration
43 #
45 # This indicates where the Roundup trackers live. They're given as NAME ->
46 # TRACKER_HOME, where the NAME part is used in the URL to select the
47 # appropriate reacker.
48 # Make sure the NAME part doesn't include any url-unsafe characters like 
49 # spaces, as these confuse the cookie handling in browsers like IE.
50 TRACKER_HOMES = {
51 #    'example': '/path/to/example',
52 }
54 ROUNDUP_USER = None
55 ROUNDUP_GROUP = None
56 ROUNDUP_LOG_IP = 1
57 HOSTNAME = ''
58 PORT = 8080
59 PIDFILE = None
60 LOGFILE = None
63 #
64 ##  end configuration
65 #
67 # "default" favicon.ico
68 # generate by using "icotool" and tools/base64
69 import zlib, base64
70 favico = zlib.decompress(base64.decodestring('''
71 eJztjr1PmlEUh59XgVoshdYPWorFIhaRFq0t9pNq37b60lYSTRzcTFw6GAfj5gDYaF0dTB0MxMSE
72 gQQd3FzKJiEC0UCIUUN1M41pV2JCXySg/0ITn5tfzvmdc+85FwT56HSc81UJjXJsk1UsNcsSqCk1
73 BS64lK+vr7OyssLJyQl2ux2j0cjU1BQajYZIJEIwGMRms+H3+zEYDExOTjI2Nsbm5iZWqxWv18vW
74 1hZDQ0Ok02kmJiY4Ojpienqa3d1dxsfHUSqVeDwe5ufnyeVyrK6u4nK5ODs7Y3FxEYfDwdzcHCaT
75 icPDQ5LJJIIgMDIyQj6fZ39/n+3tbdbW1pAkiYWFBWZmZtjb2yMejzM8PEwgEMDn85HNZonFYqjV
76 asLhMMvLy2QyGfR6PaOjowwODmKxWDg+PkalUhEKhSgUCiwtLWE2m9nZ2UGhULCxscHp6SmpVIpo
77 NMrs7CwHBwdotVoSiQRXXPG/IzY7RHtt922xjFRb01H1XhKfPBNbi/7my7rrLXJ88eppvxwEfV3f
78 NY3Y6exofVdsV3+2wnPFDdPjB83n7xuVpcFvygPbGwxF31LZIKrQDfR2Xvh7lmrX654L/7bvlnng
79 bn3Zuj8M9Hepux6VfZtW1yA6K7cfGqVu8TL325u+fHTb71QKbk+7TZQ+lTc6RcnpqW8qmVQBoj/g
80 23eo0sr/NIGvB37K+lOWXMvJ+uWFeKGU/03Cb7n3D4M3wxI=
81 '''.strip()))
83 class RoundupRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
84     TRACKER_HOMES = TRACKER_HOMES
85     ROUNDUP_USER = ROUNDUP_USER
87     def run_cgi(self):
88         """ Execute the CGI command. Wrap an innner call in an error
89             handler so all errors can be caught.
90         """
91         save_stdin = sys.stdin
92         sys.stdin = self.rfile
93         try:
94             self.inner_run_cgi()
95         except client.NotFound:
96             self.send_error(404, self.path)
97         except client.Unauthorised:
98             self.send_error(403, self.path)
99         except:
100             exc, val, tb = sys.exc_info()
101             if hasattr(socket, 'timeout') and isinstance(val, socket.timeout):
102                 # we can't send socket timeout errors back to the client, duh
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             self.send_response(200)
150             self.send_header('Content-Type', 'image/x-icon')
151             self.end_headers()
152             self.wfile.write(favico)
153             return
155         i = rest.rfind('?')
156         if i >= 0:
157             rest, query = rest[:i], rest[i+1:]
158         else:
159             query = ''
161         # no tracker - spit out the index
162         if rest == '/':
163             return self.index()
165         # figure the tracker
166         l_path = rest.split('/')
167         tracker_name = urllib.unquote(l_path[1])
169         # handle missing trailing '/'
170         if len(l_path) == 2:
171             self.send_response(301)
172             # redirect - XXX https??
173             protocol = 'http'
174             url = '%s://%s%s/'%(protocol, self.headers['host'], self.path)
175             self.send_header('Location', url)
176             self.end_headers()
177             self.wfile.write('Moved Permanently')
178             return
180         if self.TRACKER_HOMES.has_key(tracker_name):
181             tracker_home = self.TRACKER_HOMES[tracker_name]
182             tracker = roundup.instance.open(tracker_home)
183         else:
184             raise client.NotFound
186         # figure out what the rest of the path is
187         if len(l_path) > 2:
188             rest = '/'.join(l_path[2:])
189         else:
190             rest = '/'
192         # Set up the CGI environment
193         env = {}
194         env['TRACKER_NAME'] = tracker_name
195         env['REQUEST_METHOD'] = self.command
196         env['PATH_INFO'] = urllib.unquote(rest)
197         if query:
198             env['QUERY_STRING'] = query
199         host = self.address_string()
200         if self.headers.typeheader is None:
201             env['CONTENT_TYPE'] = self.headers.type
202         else:
203             env['CONTENT_TYPE'] = self.headers.typeheader
204         length = self.headers.getheader('content-length')
205         if length:
206             env['CONTENT_LENGTH'] = length
207         co = filter(None, self.headers.getheaders('cookie'))
208         if co:
209             env['HTTP_COOKIE'] = ', '.join(co)
210         env['HTTP_AUTHORIZATION'] = self.headers.getheader('authorization')
211         env['SCRIPT_NAME'] = ''
212         env['SERVER_NAME'] = self.server.server_name
213         env['SERVER_PORT'] = str(self.server.server_port)
214         env['HTTP_HOST'] = self.headers['host']
216         decoded_query = query.replace('+', ' ')
218         # do the roundup thang
219         c = tracker.Client(tracker, self, env)
220         c.main()
222     LOG_IPADDRESS = ROUNDUP_LOG_IP
223     def address_string(self):
224         if self.LOG_IPADDRESS:
225             return self.client_address[0]
226         else:
227             host, port = self.client_address
228             return socket.getfqdn(host)
230     def log_message(self, format, *args):
231         ''' Try to *safely* log to stderr.
232         '''
233         try:
234             BaseHTTPServer.BaseHTTPRequestHandler.log_message(self,
235                 format, *args)
236         except IOError:
237             # stderr is no longer viable
238             pass
240 def error():
241     exc_type, exc_value = sys.exc_info()[:2]
242     return _('Error: %s: %s' % (exc_type, exc_value))
244 try:
245     import win32serviceutil
246 except:
247     RoundupService = None
248 else:
249     # allow the win32
250     import win32service
251     import win32event
252     from win32event import *
253     from win32file import *
255     SvcShutdown = "ServiceShutdown"
257     class RoundupService(win32serviceutil.ServiceFramework,
258             BaseHTTPServer.HTTPServer):
259         ''' A Roundup standalone server for Win32 by Ewout Prangsma
260         '''
261         _svc_name_ = "Roundup Bug Tracker"
262         _svc_display_name_ = "Roundup Bug Tracker"
263         address = (HOSTNAME, PORT)
264         def __init__(self, args):
265             # redirect stdout/stderr to our logfile
266             if LOGFILE:
267                 # appending, unbuffered
268                 sys.stdout = sys.stderr = open(LOGFILE, 'a', 0)
269             win32serviceutil.ServiceFramework.__init__(self, args)
270             BaseHTTPServer.HTTPServer.__init__(self, self.address, 
271                 RoundupRequestHandler)
273             # Create the necessary NT Event synchronization objects...
274             # hevSvcStop is signaled when the SCM sends us a notification
275             # to shutdown the service.
276             self.hevSvcStop = win32event.CreateEvent(None, 0, 0, None)
278             # hevConn is signaled when we have a new incomming connection.
279             self.hevConn    = win32event.CreateEvent(None, 0, 0, None)
281             # Hang onto this module for other people to use for logging
282             # purposes.
283             import servicemanager
284             self.servicemanager = servicemanager
286         def SvcStop(self):
287             # Before we do anything, tell the SCM we are starting the
288             # stop process.
289             self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
290             win32event.SetEvent(self.hevSvcStop)
292         def SvcDoRun(self):
293             try:
294                 self.serve_forever()
295             except SvcShutdown:
296                 pass
298         def get_request(self):
299             # Call WSAEventSelect to enable self.socket to be waited on.
300             WSAEventSelect(self.socket, self.hevConn, FD_ACCEPT)
301             while 1:
302                 try:
303                     rv = self.socket.accept()
304                 except socket.error, why:
305                     if why[0] != WSAEWOULDBLOCK:
306                         raise
307                     # Use WaitForMultipleObjects instead of select() because
308                     # on NT select() is only good for sockets, and not general
309                     # NT synchronization objects.
310                     rc = WaitForMultipleObjects((self.hevSvcStop, self.hevConn),
311                         0, INFINITE)
312                     if rc == WAIT_OBJECT_0:
313                         # self.hevSvcStop was signaled, this means:
314                         # Stop the service!
315                         # So we throw the shutdown exception, which gets
316                         # caught by self.SvcDoRun
317                         raise SvcShutdown
318                     # Otherwise, rc == WAIT_OBJECT_0 + 1 which means
319                     # self.hevConn was signaled, which means when we call 
320                     # self.socket.accept(), we'll have our incoming connection
321                     # socket!
322                     # Loop back to the top, and let that accept do its thing...
323                 else:
324                     # yay! we have a connection
325                     # However... the new socket is non-blocking, we need to
326                     # set it back into blocking mode. (The socket that accept()
327                     # returns has the same properties as the listening sockets,
328                     # this includes any properties set by WSAAsyncSelect, or 
329                     # WSAEventSelect, and whether its a blocking socket or not.)
330                     #
331                     # So if you yank the following line, the setblocking() call 
332                     # will be useless. The socket will still be in non-blocking
333                     # mode.
334                     WSAEventSelect(rv[0], self.hevConn, 0)
335                     rv[0].setblocking(1)
336                     break
337             return rv
339 def usage(message=''):
340     if RoundupService:
341         win = ''' -c: Windows Service options.  If you want to run the server as a Windows
342      Service, you must configure the rest of the options by changing the
343      constants of this program.  You will at least configure one tracker
344      in the TRACKER_HOMES variable.  This option is mutually exclusive
345      from the rest.  Typing "roundup-server -c help" shows Windows
346      Services specifics.'''
347     else:
348         win = ''
349     port=PORT
350     print _('''%(message)s
351 Usage:
352 roundup-server [options] [name=tracker home]*
354 options:
355  -v: print version and exit
356  -n: sets the host name
357  -p: sets the port to listen on (default: %(port)s)
358  -u: sets the uid to this user after listening on the port
359  -g: sets the gid to this group after listening on the port
360  -l: sets a filename to log to (instead of stderr / stdout)
361  -d: run the server in the background and on UN*X write the server's PID
362      to the nominated file. The -l option *must* be specified if this
363      option is.
364  -N: log client machine names in access log instead of IP addresses (much
365      slower)
366 %(win)s
368 name=tracker home:
369    Sets the tracker home(s) to use. The name is how the tracker is
370    identified in the URL (it's the first part of the URL path). The
371    tracker home is the directory that was identified when you did
372    "roundup-admin init". You may specify any number of these name=home
373    pairs on the command-line. For convenience, you may edit the
374    TRACKER_HOMES variable in the roundup-server file instead.
375    Make sure the name part doesn't include any url-unsafe characters like 
376    spaces, as these confuse the cookie handling in browsers like IE.
377 ''')%locals()
378     sys.exit(0)
380 def daemonize(pidfile):
381     ''' Turn this process into a daemon.
382         - make sure the sys.std(in|out|err) are completely cut off
383         - make our parent PID 1
385         Write our new PID to the pidfile.
387         From A.M. Kuuchling (possibly originally Greg Ward) with
388         modification from Oren Tirosh, and finally a small mod from me.
389     '''
390     # Fork once
391     if os.fork() != 0:
392         os._exit(0)
394     # Create new session
395     os.setsid()
397     # Second fork to force PPID=1
398     pid = os.fork()
399     if pid:
400         pidfile = open(pidfile, 'w')
401         pidfile.write(str(pid))
402         pidfile.close()
403         os._exit(0)
405     os.chdir("/")
406     os.umask(0)
408     # close off sys.std(in|out|err), redirect to devnull so the file
409     # descriptors can't be used again
410     devnull = os.open('/dev/null', 0)
411     os.dup2(devnull, 0)
412     os.dup2(devnull, 1)
413     os.dup2(devnull, 2)
415 def run(port=PORT, success_message=None):
416     ''' Script entry point - handle args and figure out what to to.
417     '''
418     # time out after a minute if we can
419     import socket
420     if hasattr(socket, 'setdefaulttimeout'):
421         socket.setdefaulttimeout(60)
423     hostname = HOSTNAME
424     pidfile = PIDFILE
425     logfile = LOGFILE
426     user = ROUNDUP_USER
427     group = ROUNDUP_GROUP
428     svc_args = None
430     try:
431         # handle the command-line args
432         options = 'n:p:u:d:l:hNv'
433         if RoundupService:
434             options += 'c'
436         try:
437             optlist, args = getopt.getopt(sys.argv[1:], options)
438         except getopt.GetoptError, e:
439             usage(str(e))
441         user = ROUNDUP_USER
442         group = None
443         for (opt, arg) in optlist:
444             if opt == '-n': hostname = arg
445             elif opt == '-v':
446                 print '%s (python %s)'%(roundup_version, sys.version.split()[0])
447                 return
448             elif opt == '-p': port = int(arg)
449             elif opt == '-u': user = arg
450             elif opt == '-g': group = arg
451             elif opt == '-d': pidfile = os.path.abspath(arg)
452             elif opt == '-l': logfile = os.path.abspath(arg)
453             elif opt == '-h': usage()
454             elif opt == '-N': RoundupRequestHandler.LOG_IPADDRESS = 0
455             elif opt == '-c': svc_args = [opt] + args; args = None
457         if svc_args is not None and len(optlist) > 1:
458             raise ValueError, _("windows service option must be the only one")
460         if pidfile and not logfile:
461             raise ValueError, _("logfile *must* be specified if pidfile is")
462   
463         # obtain server before changing user id - allows to use port <
464         # 1024 if started as root
465         address = (hostname, port)
466         server_klass = BaseHTTPServer.HTTPServer
467         class server_klass(SocketServer.ForkingMixIn,
468                 BaseHTTPServer.HTTPServer):
469             pass
470         try:
471             httpd = server_klass(address, RoundupRequestHandler)
472         except socket.error, e:
473             if e[0] == errno.EADDRINUSE:
474                 raise socket.error, \
475                       _("Unable to bind to port %s, port already in use." % port)
476             raise
478         if group is not None and hasattr(os, 'getgid'):
479             # if root, setgid to the running user
480             if not os.getgid() and user is not None:
481                 try:
482                     import pwd
483                 except ImportError:
484                     raise ValueError, _("Can't change groups - no pwd module")
485                 try:
486                     gid = pwd.getpwnam(user)[3]
487                 except KeyError:
488                     raise ValueError,_("Group %(group)s doesn't exist")%locals()
489                 os.setgid(gid)
490             elif os.getgid() and user is not None:
491                 print _('WARNING: ignoring "-g" argument, not root')
493         if hasattr(os, 'getuid'):
494             # if root, setuid to the running user
495             if not os.getuid() and user is not None:
496                 try:
497                     import pwd
498                 except ImportError:
499                     raise ValueError, _("Can't change users - no pwd module")
500                 try:
501                     uid = pwd.getpwnam(user)[2]
502                 except KeyError:
503                     raise ValueError, _("User %(user)s doesn't exist")%locals()
504                 os.setuid(uid)
505             elif os.getuid() and user is not None:
506                 print _('WARNING: ignoring "-u" argument, not root')
508             # People can remove this check if they're really determined
509             if not os.getuid() and user is None:
510                 raise ValueError, _("Can't run as root!")
512         # handle tracker specs
513         if args:
514             d = {}
515             for arg in args:
516                 try:
517                     name, home = arg.split('=')
518                 except ValueError:
519                     raise ValueError, _("Instances must be name=home")
520                 d[name] = os.path.abspath(home)
521             RoundupRequestHandler.TRACKER_HOMES = d
522     except SystemExit:
523         raise
524     except ValueError:
525         usage(error())
526     except:
527         print error()
528         sys.exit(1)
530     # we don't want the cgi module interpreting the command-line args ;)
531     sys.argv = sys.argv[:1]
533     if pidfile:
534         if not hasattr(os, 'fork'):
535             print "Sorry, you can't run the server as a daemon on this" \
536                 'Operating System'
537             sys.exit(0)
538         else:
539             daemonize(pidfile)
541     if svc_args is not None:
542         # don't do any other stuff
543         return win32serviceutil.HandleCommandLine(RoundupService, argv=svc_args)
545     # redirect stdout/stderr to our logfile
546     if logfile:
547         # appending, unbuffered
548         sys.stdout = sys.stderr = open(logfile, 'a', 0)
550     if success_message:
551         print success_message
552     else:
553         print _('Roundup server started on %(address)s')%locals()
555     try:
556         httpd.serve_forever()
557     except KeyboardInterrupt:
558         print 'Keyboard Interrupt: exiting'
560 if __name__ == '__main__':
561     run()
563 # vim: set filetype=python ts=4 sw=4 et si