Code

documentation cleanup
[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.37 2004-02-11 23: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 error():
216     exc_type, exc_value = sys.exc_info()[:2]
217     return _('Error: %s: %s' % (exc_type, exc_value))
219 try:
220     import win32serviceutil
221 except:
222     RoundupService = None
223 else:
224     # allow the win32
225     import win32service
226     import win32event
227     from win32event import *
228     from win32file import *
230     SvcShutdown = "ServiceShutdown"
232     class RoundupService(win32serviceutil.ServiceFramework,
233             BaseHTTPServer.HTTPServer):
234         ''' A Roundup standalone server for Win32 by Ewout Prangsma
235         '''
236         _svc_name_ = "Roundup Bug Tracker"
237         _svc_display_name_ = "Roundup Bug Tracker"
238         address = (HOSTNAME, PORT)
239         def __init__(self, args):
240             # redirect stdout/stderr to our logfile
241             if LOGFILE:
242                 # appending, unbuffered
243                 sys.stdout = sys.stderr = open(LOGFILE, 'a', 0)
244             win32serviceutil.ServiceFramework.__init__(self, args)
245             BaseHTTPServer.HTTPServer.__init__(self, self.address, 
246                 RoundupRequestHandler)
248             # Create the necessary NT Event synchronization objects...
249             # hevSvcStop is signaled when the SCM sends us a notification
250             # to shutdown the service.
251             self.hevSvcStop = win32event.CreateEvent(None, 0, 0, None)
253             # hevConn is signaled when we have a new incomming connection.
254             self.hevConn    = win32event.CreateEvent(None, 0, 0, None)
256             # Hang onto this module for other people to use for logging
257             # purposes.
258             import servicemanager
259             self.servicemanager = servicemanager
261         def SvcStop(self):
262             # Before we do anything, tell the SCM we are starting the
263             # stop process.
264             self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
265             win32event.SetEvent(self.hevSvcStop)
267         def SvcDoRun(self):
268             try:
269                 self.serve_forever()
270             except SvcShutdown:
271                 pass
273         def get_request(self):
274             # Call WSAEventSelect to enable self.socket to be waited on.
275             WSAEventSelect(self.socket, self.hevConn, FD_ACCEPT)
276             while 1:
277                 try:
278                     rv = self.socket.accept()
279                 except socket.error, why:
280                     if why[0] != WSAEWOULDBLOCK:
281                         raise
282                     # Use WaitForMultipleObjects instead of select() because
283                     # on NT select() is only good for sockets, and not general
284                     # NT synchronization objects.
285                     rc = WaitForMultipleObjects((self.hevSvcStop, self.hevConn),
286                         0, INFINITE)
287                     if rc == WAIT_OBJECT_0:
288                         # self.hevSvcStop was signaled, this means:
289                         # Stop the service!
290                         # So we throw the shutdown exception, which gets
291                         # caught by self.SvcDoRun
292                         raise SvcShutdown
293                     # Otherwise, rc == WAIT_OBJECT_0 + 1 which means
294                     # self.hevConn was signaled, which means when we call 
295                     # self.socket.accept(), we'll have our incoming connection
296                     # socket!
297                     # Loop back to the top, and let that accept do its thing...
298                 else:
299                     # yay! we have a connection
300                     # However... the new socket is non-blocking, we need to
301                     # set it back into blocking mode. (The socket that accept()
302                     # returns has the same properties as the listening sockets,
303                     # this includes any properties set by WSAAsyncSelect, or 
304                     # WSAEventSelect, and whether its a blocking socket or not.)
305                     #
306                     # So if you yank the following line, the setblocking() call 
307                     # will be useless. The socket will still be in non-blocking
308                     # mode.
309                     WSAEventSelect(rv[0], self.hevConn, 0)
310                     rv[0].setblocking(1)
311                     break
312             return rv
314 def usage(message=''):
315     if RoundupService:
316         win = ''' -c: Windows Service options.  If you want to run the server as a Windows
317      Service, you must configure the rest of the options by changing the
318      constants of this program.  You will at least configure one tracker
319      in the TRACKER_HOMES variable.  This option is mutually exclusive
320      from the rest.  Typing "roundup-server -c help" shows Windows
321      Services specifics.'''
322     else:
323         win = ''
324     port=PORT
325     print _('''%(message)s
326 Usage:
327 roundup-server [options] [name=tracker home]*
329 options:
330  -n: sets the host name
331  -p: sets the port to listen on (default: %(port)s)
332  -u: sets the uid to this user after listening on the port
333  -g: sets the gid to this group after listening on the port
334  -l: sets a filename to log to (instead of stdout)
335  -d: run the server in the background and on UN*X write the server's PID
336      to the nominated file. The -l option *must* be specified if this
337      option is.
338  -N: log client machine names in access log instead of IP addresses (much
339      slower)
340 %(win)s
342 name=tracker home:
343    Sets the tracker home(s) to use. The name is how the tracker is
344    identified in the URL (it's the first part of the URL path). The
345    tracker home is the directory that was identified when you did
346    "roundup-admin init". You may specify any number of these name=home
347    pairs on the command-line. For convenience, you may edit the
348    TRACKER_HOMES variable in the roundup-server file instead.
349    Make sure the name part doesn't include any url-unsafe characters like 
350    spaces, as these confuse the cookie handling in browsers like IE.
351 ''')%locals()
352     sys.exit(0)
354 def daemonize(pidfile):
355     ''' Turn this process into a daemon.
356         - make sure the sys.std(in|out|err) are completely cut off
357         - make our parent PID 1
359         Write our new PID to the pidfile.
361         From A.M. Kuuchling (possibly originally Greg Ward) with
362         modification from Oren Tirosh, and finally a small mod from me.
363     '''
364     # Fork once
365     if os.fork() != 0:
366         os._exit(0)
368     # Create new session
369     os.setsid()
371     # Second fork to force PPID=1
372     pid = os.fork()
373     if pid:
374         pidfile = open(pidfile, 'w')
375         pidfile.write(str(pid))
376         pidfile.close()
377         os._exit(0)         
379     os.chdir("/")         
380     os.umask(0)
382     # close off sys.std(in|out|err), redirect to devnull so the file
383     # descriptors can't be used again
384     devnull = os.open('/dev/null', 0)
385     os.dup2(devnull, 0)
386     os.dup2(devnull, 1)
387     os.dup2(devnull, 2)
389 def run(port=PORT, success_message=None):
390     ''' Script entry point - handle args and figure out what to to.
391     '''
392     # time out after a minute if we can
393     import socket
394     if hasattr(socket, 'setdefaulttimeout'):
395         socket.setdefaulttimeout(60)
397     hostname = HOSTNAME
398     pidfile = PIDFILE
399     logfile = LOGFILE
400     user = ROUNDUP_USER
401     group = ROUNDUP_GROUP
402     svc_args = None
404     try:
405         # handle the command-line args
406         options = 'n:p:u:d:l:hN'
407         if RoundupService:
408             options += 'c'
410         try:
411             optlist, args = getopt.getopt(sys.argv[1:], options)
412         except getopt.GetoptError, e:
413             usage(str(e))
415         user = ROUNDUP_USER
416         group = None
417         for (opt, arg) in optlist:
418             if opt == '-n': hostname = arg
419             elif opt == '-p': port = int(arg)
420             elif opt == '-u': user = arg
421             elif opt == '-g': group = arg
422             elif opt == '-d': pidfile = os.path.abspath(arg)
423             elif opt == '-l': logfile = os.path.abspath(arg)
424             elif opt == '-h': usage()
425             elif opt == '-N': RoundupRequestHandler.LOG_IPADDRESS = 0
426             elif opt == '-c': svc_args = [opt] + args; args = None
428         if svc_args is not None and len(optlist) > 1:
429             raise ValueError, _("windows service option must be the only one")
431         if pidfile and not logfile:
432             raise ValueError, _("logfile *must* be specified if pidfile is")
433   
434         # obtain server before changing user id - allows to use port <
435         # 1024 if started as root
436         address = (hostname, port)
437         try:
438             httpd = BaseHTTPServer.HTTPServer(address, RoundupRequestHandler)
439         except socket.error, e:
440             if e[0] == errno.EADDRINUSE:
441                 raise socket.error, \
442                       _("Unable to bind to port %s, port already in use." % port)
443             raise
445         if group is not None and hasattr(os, 'getgid'):
446             # if root, setgid to the running user
447             if not os.getgid() and user is not None:
448                 try:
449                     import pwd
450                 except ImportError:
451                     raise ValueError, _("Can't change groups - no pwd module")
452                 try:
453                     gid = pwd.getpwnam(user)[3]
454                 except KeyError:
455                     raise ValueError,_("Group %(group)s doesn't exist")%locals()
456                 os.setgid(gid)
457             elif os.getgid() and user is not None:
458                 print _('WARNING: ignoring "-g" argument, not root')
460         if hasattr(os, 'getuid'):
461             # if root, setuid to the running user
462             if not os.getuid() and user is not None:
463                 try:
464                     import pwd
465                 except ImportError:
466                     raise ValueError, _("Can't change users - no pwd module")
467                 try:
468                     uid = pwd.getpwnam(user)[2]
469                 except KeyError:
470                     raise ValueError, _("User %(user)s doesn't exist")%locals()
471                 os.setuid(uid)
472             elif os.getuid() and user is not None:
473                 print _('WARNING: ignoring "-u" argument, not root')
475             # People can remove this check if they're really determined
476             if not os.getuid() and user is None:
477                 raise ValueError, _("Can't run as root!")
479         # handle tracker specs
480         if args:
481             d = {}
482             for arg in args:
483                 try:
484                     name, home = arg.split('=')
485                 except ValueError:
486                     raise ValueError, _("Instances must be name=home")
487                 d[name] = os.path.abspath(home)
488             RoundupRequestHandler.TRACKER_HOMES = d
489     except SystemExit:
490         raise
491     except ValueError:
492         usage(error())
493     except:
494         print error()
495         sys.exit(1)
497     # we don't want the cgi module interpreting the command-line args ;)
498     sys.argv = sys.argv[:1]
500     if pidfile:
501         if not hasattr(os, 'fork'):
502             print "Sorry, you can't run the server as a daemon on this" \
503                 'Operating System'
504             sys.exit(0)
505         else:
506             daemonize(pidfile)
508     if svc_args is not None:
509         # don't do any other stuff
510         return win32serviceutil.HandleCommandLine(RoundupService, argv=svc_args)
512     # redirect stdout/stderr to our logfile
513     if logfile:
514         # appending, unbuffered
515         sys.stdout = sys.stderr = open(logfile, 'a', 0)
517     if success_message:
518         print success_message
519     else:
520         print _('Roundup server started on %(address)s')%locals()
522     try:
523         httpd.serve_forever()
524     except KeyboardInterrupt:
525         print 'Keyboard Interrupt: exiting'
527 if __name__ == '__main__':
528     run()
530 # vim: set filetype=python ts=4 sw=4 et si