From ad862225fa763663b82e6ef681a379e5c676d944 Mon Sep 17 00:00:00 2001 From: Stefan Hacker Date: Mon, 20 Dec 2010 01:08:01 +0100 Subject: [PATCH] Add mumo.py with Ice connectivity. For now no context support. --- config.py | 3 +- config_test.py | 10 +- modules-enabled/test.ini | 9 + modules/test.py | 20 +- mumo.ini | 45 ++++ mumo.py | 453 +++++++++++++++++++++++++++++++++++++++ mumo_manager.py | 24 ++- 7 files changed, 537 insertions(+), 27 deletions(-) create mode 100644 modules-enabled/test.ini create mode 100644 mumo.ini create mode 100644 mumo.py diff --git a/config.py b/config.py index 3b87c53..4f7dbbf 100644 --- a/config.py +++ b/config.py @@ -57,8 +57,7 @@ class Config(object): self.__dict__[h] = [] else: self.__dict__[h] = Config() - for name, val in v.iteritems(): - conv, vdefault = val + for name, conv, vdefault in v: if not filename: self.__dict__[h].__dict__[name] = vdefault diff --git a/config_test.py b/config_test.py index 0f6fe2d..c675e0f 100644 --- a/config_test.py +++ b/config_test.py @@ -55,11 +55,11 @@ somenum = 10 testfallbacknum = asdas """ - cfg_default = {'world':{'domination':(x2bool, False), - 'somestr':(str, "fail"), - 'somenum':(int, 0), - 'somenumtest':(int, 1)}, - 'somethingelse':{'bla':(str, "test")}} + cfg_default = {'world':(('domination', x2bool, False), + ('somestr', str, "fail"), + ('somenum', int, 0), + ('somenumtest', int, 1)), + 'somethingelse':(('bla', str, "test"),)} def setUp(self): pass diff --git a/modules-enabled/test.ini b/modules-enabled/test.ini new file mode 100644 index 0000000..1a56004 --- /dev/null +++ b/modules-enabled/test.ini @@ -0,0 +1,9 @@ +[testing] +tvar = 1 +tvar2 = Bernd +tvar3 = -1 +tvar4 = True + +[blub] +Bernd = asdad +asdasd = dasdw \ No newline at end of file diff --git a/modules/test.py b/modules/test.py index 2864b72..4e110d1 100644 --- a/modules/test.py +++ b/modules/test.py @@ -34,32 +34,28 @@ from mumo_module import (x2bool, logModFu) class test(MumoModule): - default_config = {'testing':{'tvar': (int , 1), - 'novar': (str, 'no bernd')}} + default_config = {'testing':(('tvar', int , 1), + ('novar', str, 'no bernd'))} - def __init__(self, cfg_file, manager): - MumoModule.__init__(self, "test", manager, cfg_file) + def __init__(self, name, manager, configuration = None): + MumoModule.__init__(self, name, manager, configuration) log = self.log() cfg = self.cfg() log.debug("tvar: %s", cfg.testing.tvar) log.debug("novar: %s", cfg.testing.novar) - - @logModFu - def unload(self): - pass @logModFu def connected(self): manager = self.manager() log = self.log() log.debug("Ice connected, register for everything out there") - manager.enlistMetaCallbackHandler(self) - manager.enlistServerCallbackHandler(self, manager.SERVER_ALL_TRACK) - manager.enlistServerContextCallbackHandler(self, manager.SERVER_ALL_TRACK) + manager.subscribeMetaCallbacks(self) + manager.subscribeServerCallbacks(self, manager.SERVERS_ALL) + manager.subscribeContextCallbacks(self, manager.SERVERS_ALL) @logModFu def disconnected(self): - self.log().debug("Ice list") + self.log().debug("Ice disconnected") # #--- Meta callback functions # diff --git a/mumo.ini b/mumo.ini new file mode 100644 index 0000000..fef5608 --- /dev/null +++ b/mumo.ini @@ -0,0 +1,45 @@ + +; +;Ice configuration +; +[ice] + +; Host and port of the Ice interface on +; the target Murmur server. + +;host = 127.0.0.1 +;port = 6502 + +; If you do not define a slicefile here MuMo +; will try to automatically retrieve it from +; the server. This forces need_on_startup to +; True + +;slice = + +; Shared secret between the MuMo and the Murmur +; server. For security reason you should always +; use a shared secret. + +;secret = + +;Check Ice connection every x seconds + +;watchdog = 15 + +[modules] +;mod_dir = modules/ +;cfg_dir = modules-enabled/ +;timeout = 2 + +[system] + +;pidfile = muauth.pid + + +; Logging configuration +[log] +; Available loglevels: 10 = DEBUG (default) | 20 = INFO | 30 = WARNING | 40 = ERROR + +;level = 10 +;file = mumo.log \ No newline at end of file diff --git a/mumo.py b/mumo.py new file mode 100644 index 0000000..7ad50f6 --- /dev/null +++ b/mumo.py @@ -0,0 +1,453 @@ +#!/usr/bin/env python +# -*- coding: utf-8 + +# Copyright (C) 2010 Stefan Hacker +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: + +# - Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# - Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# - Neither the name of the Mumble Developers nor the names of its +# contributors may be used to endorse or promote products derived from this +# software without specific prior written permission. + +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +# `AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FOUNDATION OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import sys +import Ice +import logging +from config import Config, x2bool + +from threading import Timer +from optparse import OptionParser +from logging import (debug, + info, + warning, + error, + critical, + exception, + getLogger) +from mumo_manager import MumoManager + +# +#--- Default configuration values +# +cfgfile = 'mumo.ini' +default = MumoManager.cfg_default.copy() +default.update({'ice':(('host', str, '127.0.0.1'), + ('port', int, 6502), + ('slice', str, 'Murmur.ice'), + ('secret', str, ''), + ('watchdog', int, 30)), + + 'iceraw':None, + 'murmur':(('servers', lambda x:map(int, x.split(',')), []),), + 'log':(('level', int, logging.DEBUG), + ('file', str, 'mumo.log'))}) + +def do_main_program(): + # + #--- Moderator implementation + # All of this has to go in here so we can correctly daemonize the tool + # without loosing the file descriptors opened by the Ice module + Ice.loadSlice('', ['-I' + Ice.getSliceDir(), cfg.ice.slice]) + import Murmur + + class mumoIceApp(Ice.Application): + def __init__(self, manager): + Ice.Application.__init__(self) + self.manager = manager + + def run(self, args): + self.shutdownOnInterrupt() + + if not self.initializeIceConnection(): + return 1 + + if cfg.ice.watchdog > 0: + self.metaUptime = -1 + self.checkConnection() + + # Serve till we are stopped + self.communicator().waitForShutdown() + self.watchdog.cancel() + + if self.interrupted(): + warning('Caught interrupt, shutting down') + + return 0 + + def initializeIceConnection(self): + """ + Establishes the two-way Ice connection and adds the authenticator to the + configured servers + """ + ice = self.communicator() + + if cfg.ice.secret: + debug('Using shared ice secret') + ice.getImplicitContext().put("secret", cfg.ice.secret) + else: + warning('Consider using an ice secret to improve security') + + info('Connecting to Ice server (%s:%d)', cfg.ice.host, cfg.ice.port) + base = ice.stringToProxy('Meta:tcp -h %s -p %d' % (cfg.ice.host, cfg.ice.port)) + self.meta = Murmur.MetaPrx.uncheckedCast(base) + + adapter = ice.createObjectAdapterWithEndpoints('Callback.Client', 'tcp -h %s' % cfg.ice.host) + adapter.activate() + self.adapter = adapter + + metacbprx = adapter.addWithUUID(metaCallback(self)) + self.metacb = Murmur.MetaCallbackPrx.uncheckedCast(metacbprx) + + return self.attachCallbacks() + + def attachCallbacks(self): + """ + Attaches all callbacks for meta and authenticators + """ + + # Ice.ConnectionRefusedException + debug('Attaching callbacks') + try: + info('Attaching meta callback') + self.meta.addCallback(self.metacb) + + for server in self.meta.getBootedServers(): + sid = server.id() + if not cfg.murmur.servers or sid in cfg.murmur.servers: + info('Setting callbacks for virtual server %d', sid) + servercbprx = self.adapter.addWithUUID(serverCallback(self.manager, sid)) + servercb = Murmur.ServerCallbackPrx.uncheckedCast(servercbprx) + server.addCallback(servercb) + +# contextcbprx = self.adapter.addWithUUID(contextCallback(self.manager, sid)) +# contextcb = Murmur.ServerCallbackPrx.uncheckedCast(contextcbprx) +# server.addContextCallback(contextcb) + + except (Murmur.InvalidSecretException, Ice.UnknownUserException, Ice.ConnectionRefusedException), e: + if isinstance(e, Ice.ConnectionRefusedException): + error('Server refused connection') + elif isinstance(e, Murmur.InvalidSecretException) or \ + isinstance(e, Ice.UnknownUserException) and (e.unknown == 'Murmur::InvalidSecretException'): + error('Invalid ice secret') + else: + # We do not actually want to handle this one, re-raise it + raise e + + self.connected = False + self.manager.announceDisconnected() + return False + + self.connected = True + self.manager.announceConnected() + return True + + def checkConnection(self): + """ + Tries to retrieve the server uptime to determine wheter the server is + still responsive or has restarted in the meantime + """ + #debug('Watchdog run') + try: + uptime = self.meta.getUptime() + if self.metaUptime > 0: + # Check if the server didn't restart since we last checked, we assume + # since the last time we ran this check the watchdog interval +/- 5s + # have passed. This should be replaced by implementing a Keepalive in + # Murmur. + if not ((uptime - 5) <= (self.metaUptime + cfg.ice.watchdog) <= (uptime + 5)): + # Seems like the server restarted, re-attach the callbacks + self.attachCallbacks() + + self.metaUptime = uptime + except Ice.Exception, e: + error('Connection to server lost, will try to reestablish callbacks in next watchdog run (%ds)', cfg.ice.watchdog) + debug(str(e)) + self.attachCallbacks() + + # Renew the timer + self.watchdog = Timer(cfg.ice.watchdog, self.checkConnection) + self.watchdog.start() + + def checkSecret(func): + """ + Decorator that checks whether the server transmitted the right secret + if a secret is supposed to be used. + """ + if not cfg.ice.secret: + return func + + def newfunc(*args, **kws): + if 'current' in kws: + current = kws["current"] + else: + current = args[-1] + + if not current or 'secret' not in current.ctx or current.ctx['secret'] != cfg.ice.secret: + error('Server transmitted invalid secret. Possible injection attempt.') + raise Murmur.InvalidSecretException() + + return func(*args, **kws) + + return newfunc + + def fortifyIceFu(retval=None, exceptions=(Ice.Exception,)): + """ + Decorator that catches exceptions,logs them and returns a safe retval + value. This helps preventing the authenticator getting stuck in + critical code paths. Only exceptions that are instances of classes + given in the exceptions list are not caught. + + The default is to catch all non-Ice exceptions. + """ + def newdec(func): + def newfunc(*args, **kws): + try: + return func(*args, **kws) + except Exception, e: + catch = True + for ex in exceptions: + if isinstance(e, ex): + catch = False + break + + if catch: + critical('Unexpected exception caught') + exception(e) + return retval + raise + + return newfunc + return newdec + + class metaCallback(Murmur.MetaCallback): + def __init__(self, app): + Murmur.MetaCallback.__init__(self) + self.app = app + + @fortifyIceFu() + @checkSecret + def started(self, server, current=None): + """ + This function is called when a virtual server is started + and makes sure an authenticator gets attached if needed. + """ + sid = server.id() + if not cfg.murmur.servers or sid in cfg.murmur.servers: + info('Setting authenticator for virtual server %d', server.id()) + try: + servercbprx = self.app.adapter.addWithUUID(serverCallback(self.app.manager, sid)) + servercb = Murmur.ServerCallbackPrx.uncheckedCast(servercbprx) + server.addCallback(servercb) + +# contextcbprx = self.adapter.addWithUUID(contextCallback(self.app.manager, sid)) +# contextcb = Murmur.ServerCallbackPrx.uncheckedCast(contextcbprx) +# server.addContextCallback(contextcb) + # Apparently this server was restarted without us noticing + except (Murmur.InvalidSecretException, Ice.UnknownUserException), e: + if hasattr(e, "unknown") and e.unknown != "Murmur::InvalidSecretException": + # Special handling for Murmur 1.2.2 servers with invalid slice files + raise e + + error('Invalid ice secret') + return + else: + debug('Virtual server %d got started', sid) + + self.app.manager.announceMeta([sid], "started", server, current) + + @fortifyIceFu() + @checkSecret + def stopped(self, server, current=None): + """ + This function is called when a virtual server is stopped + """ + if self.app.connected: + # Only try to output the server id if we think we are still connected to prevent + # flooding of our thread pool + try: + sid = server.id() + if not cfg.murmur.servers or sid in cfg.murmur.servers: + info('Watched virtual server %d got stopped', sid) + else: + debug('Virtual server %d got stopped', sid) + self.app.manager.announceMeta([sid], "stopped", server, current) + return + except Ice.ConnectionRefusedException: + self.app.connected = False + self.app.manager.announceDisconnected() + + debug('Server shutdown stopped a virtual server') + + + def forwardServer(fu): + def new_fu(*args, **kwargs): + self = args[0] + self.manager.announceServer([self.sid], fu.__name__, *args, **kwargs) + return new_fu + + class serverCallback(Murmur.ServerCallback): + def __init__(self, manager, sid): + Murmur.ServerCallback.__init__(self) + self.manager = manager + self.sid = sid + + @forwardServer + def userStateChanged(self, u, current=None): pass + @forwardServer + def userDisconnected(self, u, current=None): pass + @forwardServer + def userConnected(self, u, current=None): pass + @forwardServer + def channelCreated(self, c, current=None): pass + @forwardServer + def channelRemoved(self, c, current=None): pass + @forwardServer + def channelStateChanged(self, c, current=None): pass + + class contextCallback(Murmur.ServerContextCallback): + def __init__(self, manager, sid): + Murmur.ServerContextCallback.__init__(self) + self.manager = manager + self.sid = sid + + def contextAction(self, action, p, session, chanid, current=None): + self.manager.announceContext([self.sid], "contextAction", action, p, session, chanid, current) + + # + #--- Start of moderator + # + info('Starting mumble moderator') + debug('Initializing manager') + manager = MumoManager() + manager.start() + manager.loadModules() + manager.startModules() + + debug('Initializing Ice') + initdata = Ice.InitializationData() + initdata.properties = Ice.createProperties([], initdata.properties) + for prop, val in cfg.iceraw: + initdata.properties.setProperty(prop, val) + + initdata.properties.setProperty('Ice.ImplicitContext', 'Shared') + initdata.logger = CustomLogger() + + app = mumoIceApp(manager) + state = app.main(sys.argv[:1], initData=initdata) + + manager.stopModules() + manager.stop() + info('Shutdown complete') + +class CustomLogger(Ice.Logger): + """ + Logger implementation to pipe Ice log messages into + out own log + """ + + def __init__(self): + Ice.Logger.__init__(self) + self._log = getLogger('Ice') + + def _print(self, message): + self._log.info(message) + + def trace(self, category, message): + self._log.debug('Trace %s: %s', category, message) + + def warning(self, message): + self._log.warning(message) + + def error(self, message): + self._log.error(message) + +# +#--- Start of program +# +if __name__ == '__main__': + # Parse commandline options + parser = OptionParser() + parser.add_option('-i', '--ini', + help='load configuration from INI', default=cfgfile) + parser.add_option('-v', '--verbose', action='store_true', dest='verbose', + help='verbose output [default]', default=True) + parser.add_option('-q', '--quiet', action='store_false', dest='verbose', + help='only error output') + parser.add_option('-d', '--daemon', action='store_true', dest='force_daemon', + help='run as daemon', default=False) + parser.add_option('-a', '--app', action='store_true', dest='force_app', + help='do not run as daemon', default=False) + (option, args) = parser.parse_args() + + if option.force_daemon and option.force_app: + parser.print_help() + sys.exit(1) + + # Load configuration + try: + cfg = Config(option.ini, default) + except Exception, e: + print >> sys.stderr, 'Fatal error, could not load config file from "%s"' % cfgfile + print >> sys.stderr, e + sys.exit(1) + + # Initialise logger + if cfg.log.file: + try: + logfile = open(cfg.log.file, 'a') + except IOError, e: + #print>>sys.stderr, str(e) + print >> sys.stderr, 'Fatal error, could not open logfile "%s"' % cfg.log.file + sys.exit(1) + else: + logfile = logging.sys.stderr + + + if option.verbose: + level = cfg.log.level + else: + level = logging.ERROR + + logging.basicConfig(level=level, + format='%(asctime)s %(levelname)s %(name)s %(message)s', + stream=logfile) + + # As the default try to run as daemon. Silently degrade to running as a normal application if this fails + # unless the user explicitly defined what he expected with the -a / -d parameter. + try: + if option.force_app: + raise ImportError # Pretend that we couldn't import the daemon lib + import daemon + except ImportError: + if option.force_daemon: + print >> sys.stderr, 'Fatal error, could not daemonize process due to missing "daemon" library, ' \ + 'please install the missing dependency and restart the authenticator' + sys.exit(1) + do_main_program() + else: + context = daemon.DaemonContext(working_directory=sys.path[0], + stderr=logfile) + context.__enter__() + try: + do_main_program() + finally: + context.__exit__(None, None, None) diff --git a/mumo_manager.py b/mumo_manager.py index b914977..43d7a42 100644 --- a/mumo_manager.py +++ b/mumo_manager.py @@ -168,9 +168,9 @@ class MumoManagerRemote(object): class MumoManager(Worker): MAGIC_ALL = -1 - cfg_default = {'modules':{'mod_dir':(str, "modules/"), - 'cfg_dir':(str, "modules-enabled/"), - 'timeout':(int, 2)}} + cfg_default = {'modules':(('mod_dir', str, "modules/"), + ('cfg_dir', str, "modules-enabled/"), + ('timeout', int, 2))} def __init__(self, cfg = Config(default = cfg_default)): Worker.__init__(self, "MumoManager") @@ -375,9 +375,9 @@ class MumoManager(Worker): names = [] for f in os.listdir(self.cfg.modules.cfg_dir): - if os.path.isfile(f): + if os.path.isfile(self.cfg.modules.cfg_dir + f): base, ext = os.path.splitext(f) - if not ext or ext.tolower() == ".ini" or ext.tolower == ".conf": + if not ext or ext.lower() == ".ini" or ext.lower() == ".conf": names.append(base) for name in names: @@ -404,7 +404,13 @@ class MumoManager(Worker): modqueue = Queue.Queue() modmanager = MumoManagerRemote(self, name, modqueue) - modinst = modcls(name, modmanager, module_cfg) + try: + modinst = modcls(name, modmanager, module_cfg) + except Exception, e: + msg = "Module '%s' failed to initialize" % name + log.error(msg) + log.exception(e) + raise FailedLoadModuleInitializationException(msg) # Remember it self.modules[name] = modinst @@ -445,7 +451,7 @@ class MumoManager(Worker): msg = "Module directory '%s' not found" % self.cfg.modules.mod_dir log.error(msg) raise FailedLoadModuleImportException(msg) - sys.path.append(self.cfg.modules.mod_dir) + sys.path.insert(0, self.cfg.modules.mod_dir) # Import the module and instanciate it try: @@ -463,7 +469,9 @@ class MumoManager(Worker): except AttributeError: modcls = getattr(mod, name) except AttributeError: - raise FailedLoadModuleInitializationException("Module does not contain required class %s" % name) + msg = "Module does not contain required class '%s'" % name + log.error(msg) + raise FailedLoadModuleInitializationException(msg) return self._loadModuleCls_noblock(name, modcls, confpath)