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