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