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.94 2007-09-25 04:27:12 jpend Exp $
21 """
22 __docformat__ = 'restructuredtext'
24 import errno, cgi, getopt, os, socket, sys, traceback, urllib, time
25 import ConfigParser, BaseHTTPServer, SocketServer, StringIO
27 try:
28 from OpenSSL import SSL
29 except ImportError:
30 SSL = None
32 # python version check
33 from roundup import configuration, version_check
34 from roundup import __version__ as roundup_version
36 # Roundup modules of use here
37 from roundup.cgi import cgitb, client
38 from roundup.cgi.PageTemplates.PageTemplate import PageTemplate
39 import roundup.instance
40 from roundup.i18n import _
42 # "default" favicon.ico
43 # generate by using "icotool" and tools/base64
44 import zlib, base64
45 favico = zlib.decompress(base64.decodestring('''
46 eJztjr1PmlEUh59XgVoshdYPWorFIhaRFq0t9pNq37b60lYSTRzcTFw6GAfj5gDYaF0dTB0MxMSE
47 gQQd3FzKJiEC0UCIUUN1M41pV2JCXySg/0ITn5tfzvmdc+85FwT56HSc81UJjXJsk1UsNcsSqCk1
48 BS64lK+vr7OyssLJyQl2ux2j0cjU1BQajYZIJEIwGMRms+H3+zEYDExOTjI2Nsbm5iZWqxWv18vW
49 1hZDQ0Ok02kmJiY4Ojpienqa3d1dxsfHUSqVeDwe5ufnyeVyrK6u4nK5ODs7Y3FxEYfDwdzcHCaT
50 icPDQ5LJJIIgMDIyQj6fZ39/n+3tbdbW1pAkiYWFBWZmZtjb2yMejzM8PEwgEMDn85HNZonFYqjV
51 asLhMMvLy2QyGfR6PaOjowwODmKxWDg+PkalUhEKhSgUCiwtLWE2m9nZ2UGhULCxscHp6SmpVIpo
52 NMrs7CwHBwdotVoSiQRXXPG/IzY7RHtt922xjFRb01H1XhKfPBNbi/7my7rrLXJ88eppvxwEfV3f
53 NY3Y6exofVdsV3+2wnPFDdPjB83n7xuVpcFvygPbGwxF31LZIKrQDfR2Xvh7lmrX654L/7bvlnng
54 bn3Zuj8M9Hepux6VfZtW1yA6K7cfGqVu8TL325u+fHTb71QKbk+7TZQ+lTc6RcnpqW8qmVQBoj/g
55 23eo0sr/NIGvB37K+lOWXMvJ+uWFeKGU/03Cb7n3D4M3wxI=
56 '''.strip()))
58 DEFAULT_PORT = 8080
60 # See what types of multiprocess server are available
61 # Note: the order is important. Preferred multiprocess type
62 # is the last element of this list.
63 # "debug" means "none" + no tracker/template cache
64 MULTIPROCESS_TYPES = ["debug", "none"]
65 try:
66 import thread
67 except ImportError:
68 pass
69 else:
70 MULTIPROCESS_TYPES.append("thread")
71 if hasattr(os, 'fork'):
72 MULTIPROCESS_TYPES.append("fork")
73 DEFAULT_MULTIPROCESS = MULTIPROCESS_TYPES[-1]
75 def auto_ssl():
76 print _('WARNING: generating temporary SSL certificate')
77 import OpenSSL, random
78 pkey = OpenSSL.crypto.PKey()
79 pkey.generate_key(OpenSSL.crypto.TYPE_RSA, 768)
80 cert = OpenSSL.crypto.X509()
81 cert.set_serial_number(random.randint(0, sys.maxint))
82 cert.gmtime_adj_notBefore(0)
83 cert.gmtime_adj_notAfter(60 * 60 * 24 * 365) # one year
84 cert.get_subject().CN = '*'
85 cert.get_subject().O = 'Roundup Dummy Certificate'
86 cert.get_issuer().CN = 'Roundup Dummy Certificate Authority'
87 cert.get_issuer().O = 'Self-Signed'
88 cert.set_pubkey(pkey)
89 cert.sign(pkey, 'md5')
90 ctx = SSL.Context(SSL.SSLv23_METHOD)
91 ctx.use_privatekey(pkey)
92 ctx.use_certificate(cert)
94 return ctx
96 class SecureHTTPServer(BaseHTTPServer.HTTPServer):
97 def __init__(self, server_address, HandlerClass, ssl_pem=None):
98 assert SSL, "pyopenssl not installed"
99 BaseHTTPServer.HTTPServer.__init__(self, server_address, HandlerClass)
100 self.socket = socket.socket(self.address_family, self.socket_type)
101 if ssl_pem:
102 ctx = SSL.Context(SSL.SSLv23_METHOD)
103 ctx.use_privatekey_file(ssl_pem)
104 ctx.use_certificate_file(ssl_pem)
105 else:
106 ctx = auto_ssl()
107 self.ssl_context = ctx
108 self.socket = SSL.Connection(ctx, self.socket)
109 self.server_bind()
110 self.server_activate()
112 def get_request(self):
113 (conn, info) = self.socket.accept()
114 if self.ssl_context:
116 class RetryingFile(object):
117 """ SSL.Connection objects can return Want__Error
118 on recv/write, meaning "try again". We'll handle
119 the try looping here """
120 def __init__(self, fileobj):
121 self.__fileobj = fileobj
123 def readline(self, *args):
124 """ SSL.Connection can return WantRead """
125 while True:
126 try:
127 return self.__fileobj.readline(*args)
128 except SSL.WantReadError:
129 time.sleep(.1)
131 def read(self, *args):
132 """ SSL.Connection can return WantRead """
133 while True:
134 try:
135 return self.__fileobj.read(*args)
136 except SSL.WantReadError:
137 time.sleep(.1)
139 def __getattr__(self, attrib):
140 return getattr(self.__fileobj, attrib)
142 class ConnFixer(object):
143 """ wraps an SSL socket so that it implements makefile
144 which the HTTP handlers require """
145 def __init__(self, conn):
146 self.__conn = conn
147 def makefile(self, mode, bufsize):
148 fo = socket._fileobject(self.__conn, mode, bufsize)
149 return RetryingFile(fo)
151 def __getattr__(self, attrib):
152 return getattr(self.__conn, attrib)
154 conn = ConnFixer(conn)
155 return (conn, info)
157 class RoundupRequestHandler(BaseHTTPServer.BaseHTTPRequestHandler):
158 TRACKER_HOMES = {}
159 TRACKERS = None
160 LOG_IPADDRESS = 1
161 DEBUG_MODE = False
162 CONFIG = None
164 def get_tracker(self, name):
165 """Return a tracker instance for given tracker name"""
166 # Note: try/except KeyError works faster that has_key() check
167 # if the key is usually found in the dictionary
168 #
169 # Return cached tracker instance if we have a tracker cache
170 if self.TRACKERS:
171 try:
172 return self.TRACKERS[name]
173 except KeyError:
174 pass
175 # No cached tracker. Look for home path.
176 try:
177 tracker_home = self.TRACKER_HOMES[name]
178 except KeyError:
179 raise client.NotFound
180 # open the instance
181 tracker = roundup.instance.open(tracker_home)
182 # and cache it if we have a tracker cache
183 if self.TRACKERS:
184 self.TRACKERS[name] = tracker
185 return tracker
187 def run_cgi(self):
188 """ Execute the CGI command. Wrap an innner call in an error
189 handler so all errors can be caught.
190 """
191 try:
192 self.inner_run_cgi()
193 except client.NotFound:
194 self.send_error(404, self.path)
195 except client.Unauthorised, message:
196 self.send_error(403, '%s (%s)'%(self.path, message))
197 except:
198 exc, val, tb = sys.exc_info()
199 if hasattr(socket, 'timeout') and isinstance(val, socket.timeout):
200 self.log_error('timeout')
201 else:
202 # it'd be nice to be able to detect if these are going to have
203 # any effect...
204 self.send_response(400)
205 self.send_header('Content-Type', 'text/html')
206 self.end_headers()
207 if self.DEBUG_MODE:
208 try:
209 reload(cgitb)
210 self.wfile.write(cgitb.breaker())
211 self.wfile.write(cgitb.html())
212 except:
213 s = StringIO.StringIO()
214 traceback.print_exc(None, s)
215 self.wfile.write("<pre>")
216 self.wfile.write(cgi.escape(s.getvalue()))
217 self.wfile.write("</pre>\n")
218 else:
219 # user feedback
220 self.wfile.write(cgitb.breaker())
221 ts = time.ctime()
222 self.wfile.write('''<p>%s: An error occurred. Please check
223 the server log for more infomation.</p>'''%ts)
224 # out to the logfile
225 print 'EXCEPTION AT', ts
226 traceback.print_exc()
228 do_GET = do_POST = do_HEAD = run_cgi
230 def index(self):
231 ''' Print up an index of the available trackers
232 '''
233 keys = self.TRACKER_HOMES.keys()
234 if len(keys) == 1:
235 self.send_response(302)
236 self.send_header('Location', urllib.quote(keys[0]) + '/index')
237 self.end_headers()
238 else:
239 self.send_response(200)
241 self.send_header('Content-Type', 'text/html')
242 self.end_headers()
243 w = self.wfile.write
245 if self.CONFIG and self.CONFIG['TEMPLATE']:
246 template = open(self.CONFIG['TEMPLATE']).read()
247 pt = PageTemplate()
248 pt.write(template)
249 extra = { 'trackers': self.TRACKERS,
250 'nothing' : None,
251 'true' : 1,
252 'false' : 0,
253 }
254 w(pt.pt_render(extra_context=extra))
255 else:
256 w(_('<html><head><title>Roundup trackers index</title></head>\n'
257 '<body><h1>Roundup trackers index</h1><ol>\n'))
258 keys.sort()
259 for tracker in keys:
260 w('<li><a href="%(tracker_url)s/index">%(tracker_name)s</a>\n'%{
261 'tracker_url': urllib.quote(tracker),
262 'tracker_name': cgi.escape(tracker)})
263 w('</ol></body></html>')
265 def inner_run_cgi(self):
266 ''' This is the inner part of the CGI handling
267 '''
268 rest = self.path
270 # file-like object for the favicon.ico file information
271 favicon_fileobj = None
273 if rest == '/favicon.ico':
274 # check to see if a custom favicon was specified, and set
275 # favicon_fileobj to the input file
276 if self.CONFIG is not None:
277 favicon_filepath = os.path.abspath(self.CONFIG['FAVICON'])
279 if os.access(favicon_filepath, os.R_OK):
280 favicon_fileobj = open(favicon_filepath, 'rb')
283 if favicon_fileobj is None:
284 favicon_fileobj = StringIO.StringIO(favico)
286 self.send_response(200)
287 self.send_header('Content-Type', 'image/x-icon')
288 self.end_headers()
290 # this bufsize is completely arbitrary, I picked 4K because it sounded good.
291 # if someone knows of a better buffer size, feel free to plug it in.
292 bufsize = 4 * 1024
293 Processing = True
294 while Processing:
295 data = favicon_fileobj.read(bufsize)
296 if len(data) > 0:
297 self.wfile.write(data)
298 else:
299 Processing = False
301 favicon_fileobj.close()
303 return
305 i = rest.rfind('?')
306 if i >= 0:
307 rest, query = rest[:i], rest[i+1:]
308 else:
309 query = ''
311 # no tracker - spit out the index
312 if rest == '/':
313 self.index()
314 return
316 # figure the tracker
317 l_path = rest.split('/')
318 tracker_name = urllib.unquote(l_path[1]).lower()
320 # handle missing trailing '/'
321 if len(l_path) == 2:
322 self.send_response(301)
323 # redirect - XXX https??
324 protocol = 'http'
325 url = '%s://%s%s/'%(protocol, self.headers['host'], self.path)
326 self.send_header('Location', url)
327 self.end_headers()
328 self.wfile.write('Moved Permanently')
329 return
331 # figure out what the rest of the path is
332 if len(l_path) > 2:
333 rest = '/'.join(l_path[2:])
334 else:
335 rest = '/'
337 # Set up the CGI environment
338 env = {}
339 env['TRACKER_NAME'] = tracker_name
340 env['REQUEST_METHOD'] = self.command
341 env['PATH_INFO'] = urllib.unquote(rest)
342 if query:
343 env['QUERY_STRING'] = query
344 if self.headers.typeheader is None:
345 env['CONTENT_TYPE'] = self.headers.type
346 else:
347 env['CONTENT_TYPE'] = self.headers.typeheader
348 length = self.headers.getheader('content-length')
349 if length:
350 env['CONTENT_LENGTH'] = length
351 co = filter(None, self.headers.getheaders('cookie'))
352 if co:
353 env['HTTP_COOKIE'] = ', '.join(co)
354 env['HTTP_AUTHORIZATION'] = self.headers.getheader('authorization')
355 env['SCRIPT_NAME'] = ''
356 env['SERVER_NAME'] = self.server.server_name
357 env['SERVER_PORT'] = str(self.server.server_port)
358 try:
359 env['HTTP_HOST'] = self.headers ['host']
360 except KeyError:
361 env['HTTP_HOST'] = ''
362 if os.environ.has_key('CGI_SHOW_TIMING'):
363 env['CGI_SHOW_TIMING'] = os.environ['CGI_SHOW_TIMING']
364 env['HTTP_ACCEPT_LANGUAGE'] = self.headers.get('accept-language')
365 range = self.headers.getheader('range')
366 if range:
367 env['HTTP_RANGE'] = range
369 # do the roundup thing
370 tracker = self.get_tracker(tracker_name)
371 tracker.Client(tracker, self, env).main()
373 def address_string(self):
374 if self.LOG_IPADDRESS:
375 return self.client_address[0]
376 else:
377 host, port = self.client_address
378 return socket.getfqdn(host)
380 def log_message(self, format, *args):
381 ''' Try to *safely* log to stderr.
382 '''
383 try:
384 BaseHTTPServer.BaseHTTPRequestHandler.log_message(self,
385 format, *args)
386 except IOError:
387 # stderr is no longer viable
388 pass
390 def start_response(self, headers, response):
391 self.send_response(response)
392 for key, value in headers:
393 self.send_header(key, value)
394 self.end_headers()
396 def error():
397 exc_type, exc_value = sys.exc_info()[:2]
398 return _('Error: %s: %s' % (exc_type, exc_value))
400 def setgid(group):
401 if group is None:
402 return
403 if not hasattr(os, 'setgid'):
404 return
406 # if root, setgid to the running user
407 if os.getuid():
408 print _('WARNING: ignoring "-g" argument, not root')
409 return
411 try:
412 import grp
413 except ImportError:
414 raise ValueError, _("Can't change groups - no grp module")
415 try:
416 try:
417 gid = int(group)
418 except ValueError:
419 gid = grp.getgrnam(group)[2]
420 else:
421 grp.getgrgid(gid)
422 except KeyError:
423 raise ValueError,_("Group %(group)s doesn't exist")%locals()
424 os.setgid(gid)
426 def setuid(user):
427 if not hasattr(os, 'getuid'):
428 return
430 # People can remove this check if they're really determined
431 if user is None:
432 if os.getuid():
433 return
434 raise ValueError, _("Can't run as root!")
436 if os.getuid():
437 print _('WARNING: ignoring "-u" argument, not root')
438 return
440 try:
441 import pwd
442 except ImportError:
443 raise ValueError, _("Can't change users - no pwd module")
444 try:
445 try:
446 uid = int(user)
447 except ValueError:
448 uid = pwd.getpwnam(user)[2]
449 else:
450 pwd.getpwuid(uid)
451 except KeyError:
452 raise ValueError, _("User %(user)s doesn't exist")%locals()
453 os.setuid(uid)
455 class TrackerHomeOption(configuration.FilePathOption):
457 # Tracker homes do not need any description strings
458 def format(self):
459 return "%(name)s = %(value)s\n" % {
460 "name": self.setting,
461 "value": self.value2str(self._value),
462 }
464 class ServerConfig(configuration.Config):
466 SETTINGS = (
467 ("main", (
468 (configuration.Option, "host", "localhost",
469 "Host name of the Roundup web server instance.\n"
470 "If left unconfigured (no 'host' setting) the default\n"
471 "will be used.\n"
472 "If empty, listen on all network interfaces.\n"
473 "If you want to explicitly listen on all\n"
474 "network interfaces, the address 0.0.0.0 is a more\n"
475 "explicit way to achieve this, the use of an empty\n"
476 "string for this purpose is deprecated and will go away\n"
477 "in a future release."),
478 (configuration.IntegerNumberOption, "port", DEFAULT_PORT,
479 "Port to listen on."),
480 (configuration.NullableFilePathOption, "favicon", "favicon.ico",
481 "Path to favicon.ico image file."
482 " If unset, built-in favicon.ico is used."),
483 (configuration.NullableOption, "user", "",
484 "User ID as which the server will answer requests.\n"
485 "In order to use this option, "
486 "the server must be run initially as root.\n"
487 "Availability: Unix."),
488 (configuration.NullableOption, "group", "",
489 "Group ID as which the server will answer requests.\n"
490 "In order to use this option, "
491 "the server must be run initially as root.\n"
492 "Availability: Unix."),
493 (configuration.BooleanOption, "nodaemon", "no",
494 "don't fork (this overrides the pidfile mechanism)'"),
495 (configuration.BooleanOption, "log_hostnames", "no",
496 "Log client machine names instead of IP addresses "
497 "(much slower)"),
498 (configuration.NullableFilePathOption, "pidfile", "",
499 "File to which the server records "
500 "the process id of the daemon.\n"
501 "If this option is not set, "
502 "the server will run in foreground\n"),
503 (configuration.NullableFilePathOption, "logfile", "",
504 "Log file path. If unset, log to stderr."),
505 (configuration.Option, "multiprocess", DEFAULT_MULTIPROCESS,
506 "Set processing of each request in separate subprocess.\n"
507 "Allowed values: %s." % ", ".join(MULTIPROCESS_TYPES)),
508 (configuration.NullableFilePathOption, "template", "",
509 "Tracker index template. If unset, built-in will be used."),
510 (configuration.BooleanOption, "ssl", "no",
511 "Enable SSL support (requires pyopenssl)"),
512 (configuration.NullableFilePathOption, "pem", "",
513 "PEM file used for SSL. A temporary self-signed certificate\n"
514 "will be used if left blank."),
515 )),
516 ("trackers", (), "Roundup trackers to serve.\n"
517 "Each option in this section defines single Roundup tracker.\n"
518 "Option name identifies the tracker and will appear in the URL.\n"
519 "Option value is tracker home directory path.\n"
520 "The path may be either absolute or relative\n"
521 "to the directory containig this config file."),
522 )
524 # options recognized by config
525 OPTIONS = {
526 "host": "n:",
527 "port": "p:",
528 "group": "g:",
529 "user": "u:",
530 "logfile": "l:",
531 "pidfile": "d:",
532 "nodaemon": "D",
533 "log_hostnames": "N",
534 "multiprocess": "t:",
535 "template": "i:",
536 "ssl": "s",
537 "pem": "e:",
538 }
540 def __init__(self, config_file=None):
541 configuration.Config.__init__(self, config_file, self.SETTINGS)
542 self.sections.append("trackers")
544 def _adjust_options(self, config):
545 """Add options for tracker homes"""
546 # return early if there are no tracker definitions.
547 # trackers must be specified on the command line.
548 if not config.has_section("trackers"):
549 return
550 # config defaults appear in all sections.
551 # filter them out.
552 defaults = config.defaults().keys()
553 for name in config.options("trackers"):
554 if name not in defaults:
555 self.add_option(TrackerHomeOption(self, "trackers", name))
557 def getopt(self, args, short_options="", long_options=(),
558 config_load_options=("C", "config"), **options
559 ):
560 options.update(self.OPTIONS)
561 return configuration.Config.getopt(self, args,
562 short_options, long_options, config_load_options, **options)
564 def _get_name(self):
565 return "Roundup server"
567 def trackers(self):
568 """Return tracker definitions as a list of (name, home) pairs"""
569 trackers = []
570 for option in self._get_section_options("trackers"):
571 trackers.append((option, os.path.abspath(
572 self["TRACKERS_" + option.upper()])))
573 return trackers
575 def set_logging(self):
576 """Initialise logging to the configured file, if any."""
577 # appending, unbuffered
578 sys.stdout = sys.stderr = open(self["LOGFILE"], 'a', 0)
580 def get_server(self):
581 """Return HTTP server object to run"""
582 # we don't want the cgi module interpreting the command-line args ;)
583 sys.argv = sys.argv[:1]
585 # preload all trackers unless we are in "debug" mode
586 tracker_homes = self.trackers()
587 if self["MULTIPROCESS"] == "debug":
588 trackers = None
589 else:
590 trackers = dict([(name, roundup.instance.open(home, optimize=1))
591 for (name, home) in tracker_homes])
593 # build customized request handler class
594 class RequestHandler(RoundupRequestHandler):
595 LOG_IPADDRESS = not self["LOG_HOSTNAMES"]
596 TRACKER_HOMES = dict(tracker_homes)
597 TRACKERS = trackers
598 DEBUG_MODE = self["MULTIPROCESS"] == "debug"
599 CONFIG = self
601 def setup(self):
602 if self.CONFIG["SSL"]:
603 # perform initial ssl handshake. This will set
604 # internal state correctly so that later closing SSL
605 # socket works (with SSL end-handshake started)
606 self.request.do_handshake()
607 RoundupRequestHandler.setup(self)
609 def finish(self):
610 RoundupRequestHandler.finish(self)
611 if self.CONFIG["SSL"]:
612 self.request.shutdown()
613 self.request.close()
615 if self["SSL"]:
616 base_server = SecureHTTPServer
617 else:
618 # time out after a minute if we can
619 # This sets the socket to non-blocking. SSL needs a blocking
620 # socket, so we do this only for non-SSL connections.
621 if hasattr(socket, 'setdefaulttimeout'):
622 socket.setdefaulttimeout(60)
623 base_server = BaseHTTPServer.HTTPServer
625 # obtain request server class
626 if self["MULTIPROCESS"] not in MULTIPROCESS_TYPES:
627 print _("Multiprocess mode \"%s\" is not available, "
628 "switching to single-process") % self["MULTIPROCESS"]
629 self["MULTIPROCESS"] = "none"
630 server_class = base_server
631 elif self["MULTIPROCESS"] == "fork":
632 class ForkingServer(SocketServer.ForkingMixIn,
633 base_server):
634 pass
635 server_class = ForkingServer
636 elif self["MULTIPROCESS"] == "thread":
637 class ThreadingServer(SocketServer.ThreadingMixIn,
638 base_server):
639 pass
640 server_class = ThreadingServer
641 else:
642 server_class = base_server
644 # obtain server before changing user id - allows to
645 # use port < 1024 if started as root
646 try:
647 args = ((self["HOST"], self["PORT"]), RequestHandler)
648 kwargs = {}
649 if self["SSL"]:
650 kwargs['ssl_pem'] = self["PEM"]
651 httpd = server_class(*args, **kwargs)
652 except socket.error, e:
653 if e[0] == errno.EADDRINUSE:
654 raise socket.error, \
655 _("Unable to bind to port %s, port already in use.") \
656 % self["PORT"]
657 raise
658 # change user and/or group
659 setgid(self["GROUP"])
660 setuid(self["USER"])
661 # return the server
662 return httpd
664 try:
665 import win32serviceutil
666 except:
667 RoundupService = None
668 else:
670 # allow the win32
671 import win32service
673 class SvcShutdown(Exception):
674 pass
676 class RoundupService(win32serviceutil.ServiceFramework):
678 _svc_name_ = "roundup"
679 _svc_display_name_ = "Roundup Bug Tracker"
681 running = 0
682 server = None
684 def SvcDoRun(self):
685 import servicemanager
686 self.ReportServiceStatus(win32service.SERVICE_START_PENDING)
687 config = ServerConfig()
688 (optlist, args) = config.getopt(sys.argv[1:])
689 if not config["LOGFILE"]:
690 servicemanager.LogMsg(servicemanager.EVENTLOG_ERROR_TYPE,
691 servicemanager.PYS_SERVICE_STOPPED,
692 (self._svc_display_name_, "\r\nMissing logfile option"))
693 self.ReportServiceStatus(win32service.SERVICE_STOPPED)
694 return
695 config.set_logging()
696 self.server = config.get_server()
697 self.running = 1
698 self.ReportServiceStatus(win32service.SERVICE_RUNNING)
699 servicemanager.LogMsg(servicemanager.EVENTLOG_INFORMATION_TYPE,
700 servicemanager.PYS_SERVICE_STARTED, (self._svc_display_name_,
701 " at %s:%s" % (config["HOST"], config["PORT"])))
702 while self.running:
703 self.server.handle_request()
704 servicemanager.LogMsg(servicemanager.EVENTLOG_INFORMATION_TYPE,
705 servicemanager.PYS_SERVICE_STOPPED,
706 (self._svc_display_name_, ""))
707 self.ReportServiceStatus(win32service.SERVICE_STOPPED)
709 def SvcStop(self):
710 self.running = 0
711 # make dummy connection to self to terminate blocking accept()
712 addr = self.server.socket.getsockname()
713 if addr[0] == "0.0.0.0":
714 addr = ("127.0.0.1", addr[1])
715 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
716 sock.connect(addr)
717 sock.close()
718 self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
720 def usage(message=''):
721 if RoundupService:
722 os_part = \
723 ""''' -c <Command> Windows Service options.
724 If you want to run the server as a Windows Service, you
725 must use configuration file to specify tracker homes.
726 Logfile option is required to run Roundup Tracker service.
727 Typing "roundup-server -c help" shows Windows Services
728 specifics.'''
729 else:
730 os_part = ""''' -u <UID> runs the Roundup web server as this UID
731 -g <GID> runs the Roundup web server as this GID
732 -d <PIDfile> run the server in the background and write the server's PID
733 to the file indicated by PIDfile. The -l option *must* be
734 specified if -d is used.'''
735 if message:
736 message += '\n'
737 print _('''%(message)sUsage: roundup-server [options] [name=tracker home]*
739 Options:
740 -v print the Roundup version number and exit
741 -h print this text and exit
742 -S create or update configuration file and exit
743 -C <fname> use configuration file <fname>
744 -n <name> set the host name of the Roundup web server instance,
745 specifies on which network interfaces to listen for
746 connections, defaults to localhost, use 0.0.0.0 to bind
747 to all network interfaces
748 -p <port> set the port to listen on (default: %(port)s)
749 -l <fname> log to the file indicated by fname instead of stderr/stdout
750 -N log client machine names instead of IP addresses (much slower)
751 -i <fname> set tracker index template
752 -s enable SSL
753 -e <fname> PEM file containing SSL key and certificate
754 -t <mode> multiprocess mode (default: %(mp_def)s).
755 Allowed values: %(mp_types)s.
756 %(os_part)s
758 Long options:
759 --version print the Roundup version number and exit
760 --help print this text and exit
761 --save-config create or update configuration file and exit
762 --config <fname> use configuration file <fname>
763 All settings of the [main] section of the configuration file
764 also may be specified in form --<name>=<value>
766 Examples:
768 roundup-server -S -C /opt/roundup/etc/roundup-server.ini \\
769 -n localhost -p 8917 -l /var/log/roundup.log \\
770 support=/var/spool/roundup-trackers/support
772 roundup-server -C /opt/roundup/etc/roundup-server.ini
774 roundup-server support=/var/spool/roundup-trackers/support
776 roundup-server -d /var/run/roundup.pid -l /var/log/roundup.log \\
777 support=/var/spool/roundup-trackers/support
779 Configuration file format:
780 Roundup Server configuration file has common .ini file format.
781 Configuration file created with 'roundup-server -S' contains
782 detailed explanations for each option. Please see that file
783 for option descriptions.
785 How to use "name=tracker home":
786 These arguments set the tracker home(s) to use. The name is how the
787 tracker is identified in the URL (it's the first part of the URL path).
788 The tracker home is the directory that was identified when you did
789 "roundup-admin init". You may specify any number of these name=home
790 pairs on the command-line. Make sure the name part doesn't include
791 any url-unsafe characters like spaces, as these confuse IE.
792 ''') % {
793 "message": message,
794 "os_part": os_part,
795 "port": DEFAULT_PORT,
796 "mp_def": DEFAULT_MULTIPROCESS,
797 "mp_types": ", ".join(MULTIPROCESS_TYPES),
798 }
801 def writepidfile(pidfile):
802 ''' Write a pidfile (only). Do not daemonize. '''
803 pid = os.getpid()
804 if pid:
805 pidfile = open(pidfile, 'w')
806 pidfile.write(str(pid))
807 pidfile.close()
809 def daemonize(pidfile):
810 ''' Turn this process into a daemon.
811 - make sure the sys.std(in|out|err) are completely cut off
812 - make our parent PID 1
814 Write our new PID to the pidfile.
816 From A.M. Kuuchling (possibly originally Greg Ward) with
817 modification from Oren Tirosh, and finally a small mod from me.
818 '''
819 # Fork once
820 if os.fork() != 0:
821 os._exit(0)
823 # Create new session
824 os.setsid()
826 # Second fork to force PPID=1
827 pid = os.fork()
828 if pid:
829 pidfile = open(pidfile, 'w')
830 pidfile.write(str(pid))
831 pidfile.close()
832 os._exit(0)
834 os.chdir("/")
836 # close off std(in|out|err), redirect to devnull so the file
837 # descriptors can't be used again
838 devnull = os.open('/dev/null', 0)
839 os.dup2(devnull, 0)
840 os.dup2(devnull, 1)
841 os.dup2(devnull, 2)
843 undefined = []
844 def run(port=undefined, success_message=None):
845 ''' Script entry point - handle args and figure out what to to.
846 '''
847 config = ServerConfig()
848 # additional options
849 short_options = "hvS"
850 if RoundupService:
851 short_options += 'c'
852 try:
853 (optlist, args) = config.getopt(sys.argv[1:],
854 short_options, ("help", "version", "save-config",))
855 except (getopt.GetoptError, configuration.ConfigurationError), e:
856 usage(str(e))
857 return
859 # if running in windows service mode, don't do any other stuff
860 if ("-c", "") in optlist:
861 # acquire command line options recognized by service
862 short_options = "cC:"
863 long_options = ["config"]
864 for (long_name, short_name) in config.OPTIONS.items():
865 short_options += short_name
866 long_name = long_name.lower().replace("_", "-")
867 if short_name[-1] == ":":
868 long_name += "="
869 long_options.append(long_name)
870 optlist = getopt.getopt(sys.argv[1:], short_options, long_options)[0]
871 svc_args = []
872 for (opt, arg) in optlist:
873 if opt in ("-C", "-l"):
874 # make sure file name is absolute
875 svc_args.extend((opt, os.path.abspath(arg)))
876 elif opt in ("--config", "--logfile"):
877 # ditto, for long options
878 svc_args.append("=".join(opt, os.path.abspath(arg)))
879 elif opt != "-c":
880 svc_args.extend(opt)
881 RoundupService._exe_args_ = " ".join(svc_args)
882 # pass the control to serviceutil
883 win32serviceutil.HandleCommandLine(RoundupService,
884 argv=sys.argv[:1] + args)
885 return
887 # add tracker names from command line.
888 # this is done early to let '--save-config' handle the trackers.
889 if args:
890 for arg in args:
891 try:
892 name, home = arg.split('=')
893 except ValueError:
894 raise ValueError, _("Instances must be name=home")
895 config.add_option(TrackerHomeOption(config, "trackers", name))
896 config["TRACKERS_" + name.upper()] = home
898 # handle remaining options
899 if optlist:
900 for (opt, arg) in optlist:
901 if opt in ("-h", "--help"):
902 usage()
903 elif opt in ("-v", "--version"):
904 print '%s (python %s)' % (roundup_version,
905 sys.version.split()[0])
906 elif opt in ("-S", "--save-config"):
907 config.save()
908 print _("Configuration saved to %s") % config.filepath
909 # any of the above options prevent server from running
910 return
912 # port number in function arguments overrides config and command line
913 if port is not undefined:
914 config.PORT = port
916 if config["LOGFILE"]:
917 config["LOGFILE"] = os.path.abspath(config["LOGFILE"])
918 # switch logging from stderr/stdout to logfile
919 config.set_logging()
920 if config["PIDFILE"]:
921 config["PIDFILE"] = os.path.abspath(config["PIDFILE"])
923 # fork the server from our parent if a pidfile is specified
924 if config["PIDFILE"]:
925 if not hasattr(os, 'fork'):
926 print _("Sorry, you can't run the server as a daemon"
927 " on this Operating System")
928 sys.exit(0)
929 else:
930 if config['NODAEMON']:
931 writepidfile(config["PIDFILE"])
932 else:
933 daemonize(config["PIDFILE"])
935 # create the server
936 httpd = config.get_server()
938 if success_message:
939 print success_message
940 else:
941 print _('Roundup server started on %(HOST)s:%(PORT)s') \
942 % config
944 try:
945 httpd.serve_forever()
946 except KeyboardInterrupt:
947 print 'Keyboard Interrupt: exiting'
949 if __name__ == '__main__':
950 run()
952 # vim: sts=4 sw=4 et si