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