Code

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