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")
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