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