Code

Forward-porting of fixes from the maintenance branch.
[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.39 2004-02-15 21:55:10 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 RoundupRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
74     TRACKER_HOMES = TRACKER_HOMES
75     ROUNDUP_USER = ROUNDUP_USER
77     def run_cgi(self):
78         """ Execute the CGI command. Wrap an innner call in an error
79             handler so all errors can be caught.
80         """
81         save_stdin = sys.stdin
82         sys.stdin = self.rfile
83         try:
84             self.inner_run_cgi()
85         except client.NotFound:
86             self.send_error(404, self.path)
87         except client.Unauthorised:
88             self.send_error(403, self.path)
89         except:
90             exc, val, tb = sys.exc_info()
91             if hasattr(socket, 'timeout') and exc == socket.timeout:
92                 s = StringIO.StringIO()
93                 traceback.print_exc(None, s)
94                 self.log_message(str(s.getvalue()))
95             else:
96                 # it'd be nice to be able to detect if these are going to have
97                 # any effect...
98                 self.send_response(400)
99                 self.send_header('Content-Type', 'text/html')
100                 self.end_headers()
101                 try:
102                     reload(cgitb)
103                     self.wfile.write(cgitb.breaker())
104                     self.wfile.write(cgitb.html())
105                 except:
106                     s = StringIO.StringIO()
107                     traceback.print_exc(None, s)
108                     self.wfile.write("<pre>")
109                     self.wfile.write(cgi.escape(s.getvalue()))
110                     self.wfile.write("</pre>\n")
111         sys.stdin = save_stdin
113     do_GET = do_POST = run_cgi
115     def index(self):
116         ''' Print up an index of the available trackers
117         '''
118         self.send_response(200)
119         self.send_header('Content-Type', 'text/html')
120         self.end_headers()
121         w = self.wfile.write
122         w(_('<html><head><title>Roundup trackers index</title></head>\n'))
123         w(_('<body><h1>Roundup trackers index</h1><ol>\n'))
124         keys = self.TRACKER_HOMES.keys()
125         keys.sort()
126         for tracker in keys:
127             w(_('<li><a href="%(tracker_url)s/index">%(tracker_name)s</a>\n')%{
128                 'tracker_url': urllib.quote(tracker),
129                 'tracker_name': cgi.escape(tracker)})
130         w(_('</ol></body></html>'))
132     def inner_run_cgi(self):
133         ''' This is the inner part of the CGI handling
134         '''
135         rest = self.path
137         if rest == '/favicon.ico':
138             raise client.NotFound
140         i = rest.rfind('?')
141         if i >= 0:
142             rest, query = rest[:i], rest[i+1:]
143         else:
144             query = ''
146         # no tracker - spit out the index
147         if rest == '/':
148             return self.index()
150         # figure the tracker
151         l_path = rest.split('/')
152         tracker_name = urllib.unquote(l_path[1])
154         # handle missing trailing '/'
155         if len(l_path) == 2:
156             self.send_response(301)
157             # redirect - XXX https??
158             protocol = 'http'
159             url = '%s://%s%s/'%(protocol, self.headers['host'], self.path)
160             self.send_header('Location', url)
161             self.end_headers()
162             self.wfile.write('Moved Permanently')
163             return
165         if self.TRACKER_HOMES.has_key(tracker_name):
166             tracker_home = self.TRACKER_HOMES[tracker_name]
167             tracker = roundup.instance.open(tracker_home)
168         else:
169             raise client.NotFound
171         # figure out what the rest of the path is
172         if len(l_path) > 2:
173             rest = '/'.join(l_path[2:])
174         else:
175             rest = '/'
177         # Set up the CGI environment
178         env = {}
179         env['TRACKER_NAME'] = tracker_name
180         env['REQUEST_METHOD'] = self.command
181         env['PATH_INFO'] = urllib.unquote(rest)
182         if query:
183             env['QUERY_STRING'] = query
184         host = self.address_string()
185         if self.headers.typeheader is None:
186             env['CONTENT_TYPE'] = self.headers.type
187         else:
188             env['CONTENT_TYPE'] = self.headers.typeheader
189         length = self.headers.getheader('content-length')
190         if length:
191             env['CONTENT_LENGTH'] = length
192         co = filter(None, self.headers.getheaders('cookie'))
193         if co:
194             env['HTTP_COOKIE'] = ', '.join(co)
195         env['HTTP_AUTHORIZATION'] = self.headers.getheader('authorization')
196         env['SCRIPT_NAME'] = ''
197         env['SERVER_NAME'] = self.server.server_name
198         env['SERVER_PORT'] = str(self.server.server_port)
199         env['HTTP_HOST'] = self.headers['host']
201         decoded_query = query.replace('+', ' ')
203         # do the roundup thang
204         c = tracker.Client(tracker, self, env)
205         c.main()
207     LOG_IPADDRESS = ROUNDUP_LOG_IP
208     def address_string(self):
209         if self.LOG_IPADDRESS:
210             return self.client_address[0]
211         else:
212             host, port = self.client_address
213             return socket.getfqdn(host)
215     def log_message(self, format, *args):
216         ''' Try to *safely* log to stderr.
217         '''
218         try:
219             BaseHTTPServer.BaseHTTPRequestHandler.log_message(self,
220                 format, *args)
221         except IOError:
222             # stderr is no longer viable
223             pass
225 def error():
226     exc_type, exc_value = sys.exc_info()[:2]
227     return _('Error: %s: %s' % (exc_type, exc_value))
229 try:
230     import win32serviceutil
231 except:
232     RoundupService = None
233 else:
234     # allow the win32
235     import win32service
236     import win32event
237     from win32event import *
238     from win32file import *
240     SvcShutdown = "ServiceShutdown"
242     class RoundupService(win32serviceutil.ServiceFramework,
243             BaseHTTPServer.HTTPServer):
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             BaseHTTPServer.HTTPServer.__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 stderr / 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 = BaseHTTPServer.HTTPServer(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