Code

version info in scripts
[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.43 2004-04-05 23:43:04 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 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 exc == socket.timeout:
102                 s = StringIO.StringIO()
103                 traceback.print_exc(None, s)
104                 self.log_message(str(s.getvalue()))
105             else:
106                 # it'd be nice to be able to detect if these are going to have
107                 # any effect...
108                 self.send_response(400)
109                 self.send_header('Content-Type', 'text/html')
110                 self.end_headers()
111                 try:
112                     reload(cgitb)
113                     self.wfile.write(cgitb.breaker())
114                     self.wfile.write(cgitb.html())
115                 except:
116                     s = StringIO.StringIO()
117                     traceback.print_exc(None, s)
118                     self.wfile.write("<pre>")
119                     self.wfile.write(cgi.escape(s.getvalue()))
120                     self.wfile.write("</pre>\n")
121         sys.stdin = save_stdin
123     do_GET = do_POST = run_cgi
125     def index(self):
126         ''' Print up an index of the available trackers
127         '''
128         self.send_response(200)
129         self.send_header('Content-Type', 'text/html')
130         self.end_headers()
131         w = self.wfile.write
132         w(_('<html><head><title>Roundup trackers index</title></head>\n'))
133         w(_('<body><h1>Roundup trackers index</h1><ol>\n'))
134         keys = self.TRACKER_HOMES.keys()
135         keys.sort()
136         for tracker in keys:
137             w(_('<li><a href="%(tracker_url)s/index">%(tracker_name)s</a>\n')%{
138                 'tracker_url': urllib.quote(tracker),
139                 'tracker_name': cgi.escape(tracker)})
140         w(_('</ol></body></html>'))
142     def inner_run_cgi(self):
143         ''' This is the inner part of the CGI handling
144         '''
145         rest = self.path
147         if rest == '/favicon.ico':
148             self.send_response(200)
149             self.send_header('Content-Type', 'image/x-icon')
150             self.end_headers()
151             self.wfile.write(favico)
152             return
154         i = rest.rfind('?')
155         if i >= 0:
156             rest, query = rest[:i], rest[i+1:]
157         else:
158             query = ''
160         # no tracker - spit out the index
161         if rest == '/':
162             return self.index()
164         # figure the tracker
165         l_path = rest.split('/')
166         tracker_name = urllib.unquote(l_path[1])
168         # handle missing trailing '/'
169         if len(l_path) == 2:
170             self.send_response(301)
171             # redirect - XXX https??
172             protocol = 'http'
173             url = '%s://%s%s/'%(protocol, self.headers['host'], self.path)
174             self.send_header('Location', url)
175             self.end_headers()
176             self.wfile.write('Moved Permanently')
177             return
179         if self.TRACKER_HOMES.has_key(tracker_name):
180             tracker_home = self.TRACKER_HOMES[tracker_name]
181             tracker = roundup.instance.open(tracker_home)
182         else:
183             raise client.NotFound
185         # figure out what the rest of the path is
186         if len(l_path) > 2:
187             rest = '/'.join(l_path[2:])
188         else:
189             rest = '/'
191         # Set up the CGI environment
192         env = {}
193         env['TRACKER_NAME'] = tracker_name
194         env['REQUEST_METHOD'] = self.command
195         env['PATH_INFO'] = urllib.unquote(rest)
196         if query:
197             env['QUERY_STRING'] = query
198         host = self.address_string()
199         if self.headers.typeheader is None:
200             env['CONTENT_TYPE'] = self.headers.type
201         else:
202             env['CONTENT_TYPE'] = self.headers.typeheader
203         length = self.headers.getheader('content-length')
204         if length:
205             env['CONTENT_LENGTH'] = length
206         co = filter(None, self.headers.getheaders('cookie'))
207         if co:
208             env['HTTP_COOKIE'] = ', '.join(co)
209         env['HTTP_AUTHORIZATION'] = self.headers.getheader('authorization')
210         env['SCRIPT_NAME'] = ''
211         env['SERVER_NAME'] = self.server.server_name
212         env['SERVER_PORT'] = str(self.server.server_port)
213         env['HTTP_HOST'] = self.headers['host']
215         decoded_query = query.replace('+', ' ')
217         # do the roundup thang
218         c = tracker.Client(tracker, self, env)
219         c.main()
221     LOG_IPADDRESS = ROUNDUP_LOG_IP
222     def address_string(self):
223         if self.LOG_IPADDRESS:
224             return self.client_address[0]
225         else:
226             host, port = self.client_address
227             return socket.getfqdn(host)
229     def log_message(self, format, *args):
230         ''' Try to *safely* log to stderr.
231         '''
232         try:
233             BaseHTTPServer.BaseHTTPRequestHandler.log_message(self,
234                 format, *args)
235         except IOError:
236             # stderr is no longer viable
237             pass
239 def error():
240     exc_type, exc_value = sys.exc_info()[:2]
241     return _('Error: %s: %s' % (exc_type, exc_value))
243 try:
244     import win32serviceutil
245 except:
246     RoundupService = None
247 else:
248     # allow the win32
249     import win32service
250     import win32event
251     from win32event import *
252     from win32file import *
254     SvcShutdown = "ServiceShutdown"
256     class RoundupService(win32serviceutil.ServiceFramework,
257             BaseHTTPServer.HTTPServer):
258         ''' A Roundup standalone server for Win32 by Ewout Prangsma
259         '''
260         _svc_name_ = "Roundup Bug Tracker"
261         _svc_display_name_ = "Roundup Bug Tracker"
262         address = (HOSTNAME, PORT)
263         def __init__(self, args):
264             # redirect stdout/stderr to our logfile
265             if LOGFILE:
266                 # appending, unbuffered
267                 sys.stdout = sys.stderr = open(LOGFILE, 'a', 0)
268             win32serviceutil.ServiceFramework.__init__(self, args)
269             BaseHTTPServer.HTTPServer.__init__(self, self.address, 
270                 RoundupRequestHandler)
272             # Create the necessary NT Event synchronization objects...
273             # hevSvcStop is signaled when the SCM sends us a notification
274             # to shutdown the service.
275             self.hevSvcStop = win32event.CreateEvent(None, 0, 0, None)
277             # hevConn is signaled when we have a new incomming connection.
278             self.hevConn    = win32event.CreateEvent(None, 0, 0, None)
280             # Hang onto this module for other people to use for logging
281             # purposes.
282             import servicemanager
283             self.servicemanager = servicemanager
285         def SvcStop(self):
286             # Before we do anything, tell the SCM we are starting the
287             # stop process.
288             self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
289             win32event.SetEvent(self.hevSvcStop)
291         def SvcDoRun(self):
292             try:
293                 self.serve_forever()
294             except SvcShutdown:
295                 pass
297         def get_request(self):
298             # Call WSAEventSelect to enable self.socket to be waited on.
299             WSAEventSelect(self.socket, self.hevConn, FD_ACCEPT)
300             while 1:
301                 try:
302                     rv = self.socket.accept()
303                 except socket.error, why:
304                     if why[0] != WSAEWOULDBLOCK:
305                         raise
306                     # Use WaitForMultipleObjects instead of select() because
307                     # on NT select() is only good for sockets, and not general
308                     # NT synchronization objects.
309                     rc = WaitForMultipleObjects((self.hevSvcStop, self.hevConn),
310                         0, INFINITE)
311                     if rc == WAIT_OBJECT_0:
312                         # self.hevSvcStop was signaled, this means:
313                         # Stop the service!
314                         # So we throw the shutdown exception, which gets
315                         # caught by self.SvcDoRun
316                         raise SvcShutdown
317                     # Otherwise, rc == WAIT_OBJECT_0 + 1 which means
318                     # self.hevConn was signaled, which means when we call 
319                     # self.socket.accept(), we'll have our incoming connection
320                     # socket!
321                     # Loop back to the top, and let that accept do its thing...
322                 else:
323                     # yay! we have a connection
324                     # However... the new socket is non-blocking, we need to
325                     # set it back into blocking mode. (The socket that accept()
326                     # returns has the same properties as the listening sockets,
327                     # this includes any properties set by WSAAsyncSelect, or 
328                     # WSAEventSelect, and whether its a blocking socket or not.)
329                     #
330                     # So if you yank the following line, the setblocking() call 
331                     # will be useless. The socket will still be in non-blocking
332                     # mode.
333                     WSAEventSelect(rv[0], self.hevConn, 0)
334                     rv[0].setblocking(1)
335                     break
336             return rv
338 def usage(message=''):
339     if RoundupService:
340         win = ''' -c: Windows Service options.  If you want to run the server as a Windows
341      Service, you must configure the rest of the options by changing the
342      constants of this program.  You will at least configure one tracker
343      in the TRACKER_HOMES variable.  This option is mutually exclusive
344      from the rest.  Typing "roundup-server -c help" shows Windows
345      Services specifics.'''
346     else:
347         win = ''
348     port=PORT
349     print _('''%(message)s
350 Usage:
351 roundup-server [options] [name=tracker home]*
353 options:
354  -v: print version and exit
355  -n: sets the host name
356  -p: sets the port to listen on (default: %(port)s)
357  -u: sets the uid to this user after listening on the port
358  -g: sets the gid to this group after listening on the port
359  -l: sets a filename to log to (instead of stderr / stdout)
360  -d: run the server in the background and on UN*X write the server's PID
361      to the nominated file. The -l option *must* be specified if this
362      option is.
363  -N: log client machine names in access log instead of IP addresses (much
364      slower)
365 %(win)s
367 name=tracker home:
368    Sets the tracker home(s) to use. The name is how the tracker is
369    identified in the URL (it's the first part of the URL path). The
370    tracker home is the directory that was identified when you did
371    "roundup-admin init". You may specify any number of these name=home
372    pairs on the command-line. For convenience, you may edit the
373    TRACKER_HOMES variable in the roundup-server file instead.
374    Make sure the name part doesn't include any url-unsafe characters like 
375    spaces, as these confuse the cookie handling in browsers like IE.
376 ''')%locals()
377     sys.exit(0)
379 def daemonize(pidfile):
380     ''' Turn this process into a daemon.
381         - make sure the sys.std(in|out|err) are completely cut off
382         - make our parent PID 1
384         Write our new PID to the pidfile.
386         From A.M. Kuuchling (possibly originally Greg Ward) with
387         modification from Oren Tirosh, and finally a small mod from me.
388     '''
389     # Fork once
390     if os.fork() != 0:
391         os._exit(0)
393     # Create new session
394     os.setsid()
396     # Second fork to force PPID=1
397     pid = os.fork()
398     if pid:
399         pidfile = open(pidfile, 'w')
400         pidfile.write(str(pid))
401         pidfile.close()
402         os._exit(0)
404     os.chdir("/")
405     os.umask(0)
407     # close off sys.std(in|out|err), redirect to devnull so the file
408     # descriptors can't be used again
409     devnull = os.open('/dev/null', 0)
410     os.dup2(devnull, 0)
411     os.dup2(devnull, 1)
412     os.dup2(devnull, 2)
414 def run(port=PORT, success_message=None):
415     ''' Script entry point - handle args and figure out what to to.
416     '''
417     # time out after a minute if we can
418     import socket
419     if hasattr(socket, 'setdefaulttimeout'):
420         socket.setdefaulttimeout(60)
422     hostname = HOSTNAME
423     pidfile = PIDFILE
424     logfile = LOGFILE
425     user = ROUNDUP_USER
426     group = ROUNDUP_GROUP
427     svc_args = None
429     try:
430         # handle the command-line args
431         options = 'n:p:u:d:l:hNv'
432         if RoundupService:
433             options += 'c'
435         try:
436             optlist, args = getopt.getopt(sys.argv[1:], options)
437         except getopt.GetoptError, e:
438             usage(str(e))
440         user = ROUNDUP_USER
441         group = None
442         for (opt, arg) in optlist:
443             if opt == '-n': hostname = arg
444             elif opt == '-v':
445                 print '%s (python %s)'%(roundup_version, sys.version.split()[0])
446                 return
447             elif opt == '-p': port = int(arg)
448             elif opt == '-u': user = arg
449             elif opt == '-g': group = arg
450             elif opt == '-d': pidfile = os.path.abspath(arg)
451             elif opt == '-l': logfile = os.path.abspath(arg)
452             elif opt == '-h': usage()
453             elif opt == '-N': RoundupRequestHandler.LOG_IPADDRESS = 0
454             elif opt == '-c': svc_args = [opt] + args; args = None
456         if svc_args is not None and len(optlist) > 1:
457             raise ValueError, _("windows service option must be the only one")
459         if pidfile and not logfile:
460             raise ValueError, _("logfile *must* be specified if pidfile is")
461   
462         # obtain server before changing user id - allows to use port <
463         # 1024 if started as root
464         address = (hostname, port)
465         try:
466             httpd = BaseHTTPServer.HTTPServer(address, RoundupRequestHandler)
467         except socket.error, e:
468             if e[0] == errno.EADDRINUSE:
469                 raise socket.error, \
470                       _("Unable to bind to port %s, port already in use." % port)
471             raise
473         if group is not None and hasattr(os, 'getgid'):
474             # if root, setgid to the running user
475             if not os.getgid() and user is not None:
476                 try:
477                     import pwd
478                 except ImportError:
479                     raise ValueError, _("Can't change groups - no pwd module")
480                 try:
481                     gid = pwd.getpwnam(user)[3]
482                 except KeyError:
483                     raise ValueError,_("Group %(group)s doesn't exist")%locals()
484                 os.setgid(gid)
485             elif os.getgid() and user is not None:
486                 print _('WARNING: ignoring "-g" argument, not root')
488         if hasattr(os, 'getuid'):
489             # if root, setuid to the running user
490             if not os.getuid() and user is not None:
491                 try:
492                     import pwd
493                 except ImportError:
494                     raise ValueError, _("Can't change users - no pwd module")
495                 try:
496                     uid = pwd.getpwnam(user)[2]
497                 except KeyError:
498                     raise ValueError, _("User %(user)s doesn't exist")%locals()
499                 os.setuid(uid)
500             elif os.getuid() and user is not None:
501                 print _('WARNING: ignoring "-u" argument, not root')
503             # People can remove this check if they're really determined
504             if not os.getuid() and user is None:
505                 raise ValueError, _("Can't run as root!")
507         # handle tracker specs
508         if args:
509             d = {}
510             for arg in args:
511                 try:
512                     name, home = arg.split('=')
513                 except ValueError:
514                     raise ValueError, _("Instances must be name=home")
515                 d[name] = os.path.abspath(home)
516             RoundupRequestHandler.TRACKER_HOMES = d
517     except SystemExit:
518         raise
519     except ValueError:
520         usage(error())
521     except:
522         print error()
523         sys.exit(1)
525     # we don't want the cgi module interpreting the command-line args ;)
526     sys.argv = sys.argv[:1]
528     if pidfile:
529         if not hasattr(os, 'fork'):
530             print "Sorry, you can't run the server as a daemon on this" \
531                 'Operating System'
532             sys.exit(0)
533         else:
534             daemonize(pidfile)
536     if svc_args is not None:
537         # don't do any other stuff
538         return win32serviceutil.HandleCommandLine(RoundupService, argv=svc_args)
540     # redirect stdout/stderr to our logfile
541     if logfile:
542         # appending, unbuffered
543         sys.stdout = sys.stderr = open(logfile, 'a', 0)
545     if success_message:
546         print success_message
547     else:
548         print _('Roundup server started on %(address)s')%locals()
550     try:
551         httpd.serve_forever()
552     except KeyboardInterrupt:
553         print 'Keyboard Interrupt: exiting'
555 if __name__ == '__main__':
556     run()
558 # vim: set filetype=python ts=4 sw=4 et si