# -*- test-case-name: mv3d.test.net.test_network -*- # Copyright (C) 2008-2012 Mortal Coil Games # See LICENSE for details. """ Various network server related services """ import sys import traceback import logging import md5 from mv3d.util.classgen import getClass try: import cjson except ImportError: try: import json class Json(object): def encode(self, data): return json.dumps(data) def decode(self, data): return json.loads(data) cjson = Json() except ImportError: cjson = None from Crypto.Cipher import AES from base64 import b64decode from time import time from twisted.spread import pb from twisted.internet import defer, reactor, ssl from twisted.cred.checkers import ICredentialsChecker from twisted.cred import credentials, portal, checkers from twisted.cred.error import UnauthorizedLogin from twisted.application.service import Service from nevow import loaders, rend, inevow, guard, appserver, static from zope.interface import implements #@UnresolvedImport from mv3d.net.client import ServiceLoc, LocalSession from mv3d.util.iservice import IService, IPBServer from mv3d.util.conductor import parsePermissionConfig, parseInterfaceConfig, \ getHostIp from mv3d.net.security import Securable, AccessDenied, requirePermissions from mv3d.reporter.event import Filter, FilterCondition, EventSubscription from twisted.internet.defer import inlineCallbacks, returnValue, _DefGen_Return from twisted.python.log import deferr from mv3d.server.service import checkServicePermissions, viewed from mv3d.server.iserver import IAccountService class AnonymousClient: """ This is a client that is anonymous """ def __init__(self, session=None): self.session = session self.accountInfo = {} self.authenticated = False def getName(self): return "anonymous" def getGroups(self): return [] def getSessionID(self): return self.session anonymous = AnonymousClient() class WebClient: """ This is an authenticated web client """ def __init__(self, accountInfo, session=None): self.session = session self.accountInfo = accountInfo self.authenticated = True def getName(self): return self.accountInfo["username"] def getGroups(self): return self.accountInfo["groups"] def getSessionID(self): return self.session class PBRemoteClient(pb.Avatar): """ This is a client connection """ controller = None accountInfo = None pingtime = None lastping = None def __init__(self, parent, controller): self.parent = parent self.controller = controller self.remoteclasses = list() self.parent.parent.log("Connection initiated", logging.INFO) filter1 = Filter() filter1.addRule(FilterCondition(item="category", test="==", parameters=["chat"])) filter2 = Filter() filter2.addRule(FilterCondition(item="category", test="==", parameters=["server"])) esub = EventSubscription(self.receiveEvent) esub.addFilter(filter1) esub.addFilter(filter2) self.parent.parent.getEventService().subscribe(esub) self.eventservice = esub def getName(self): """ Returns the username associated with this client """ return self.accountInfo["username"] def getGroups(self): """ Returns the groups this client is a member of """ return self.accountInfo["groups"] def getRemoteLocation(self): """ Return the endpoint of this connection as a ServiceLoc """ return ServiceLoc(protocol="pb", host=self.controller.transport.client[0], port=self.controller.transport.client[1]) def disconnected(self): """ Called when a client disconnects """ self.controller = None self.parent.connectionCount -= 1 self.parent.parent.getEventService().unsubscribe(self.eventservice) self.parent.parent.log("Connection lost", logging.INFO) self.parent.clients.remove(self) def perspective_getService(self, name): """ Retrieve an interface to a service if available. Defer this to our parent because they have a list of published services. """ return self.parent.getService(self, name) def perspective_getServiceByInterface(self, interface): """ Retrieve an interface to a service if available. Defer this to our parent because they have a list of published services. """ return self.parent.getServiceByInterface(self, interface) def isConnected(self): """ Determine if this client is connected still """ return (self.controller is not None and not self.controller.broker.disconnected) def ping(self): """ Send a ping request to the client """ dd = self.controller.callRemote('ping', time(), self.pingtime) def pong(t): self.lastping = self.pingtime self.pingtime = time() - t return self.pingtime dd.addCallback(pong) return dd def readyClass(self, c): """ Get a class ready to be sent to the client, but don't ready the same class twice. Takes a single ClassGenerator or a list of them. """ if not isinstance(c, list): cgl = [c] else: cgl = c cs = [] for cl in cgl: key = ".".join([cl.modulename, cl.classname]) if not key in self.remoteclasses: self.remoteclasses.append(key) cs.append(cl) if not len(cs): return defer.succeed(True) return self.controller.callRemote("readyClass", cs) def receiveEvent(self, event): """ Push an event to our client """ if self.controller is not None: self.controller.callRemote("receiveEvent", event.convertToNouns()) class PBServerView(pb.Viewable): """ A perspective broker server """ def __init__(self, service): self.service = service def getProtocol(self): """ Return what protocol we implement """ return "pb" @checkServicePermissions("read") @viewed def view_getStatistics(self, client): """ Gets a dict of stats """ class PBServer(Service, Securable): """ A perspective broker server """ implements(portal.IRealm, ICredentialsChecker, IPBServer) credentialInterfaces = (credentials.IUsernameHashedPassword,) port = 1999 authenticators = None services = None connectionCount = 0 def __init__(self): Securable.__init__(self) self.localAccounts = {} self.interfaces = {} self.clients = [] self.host = getHostIp() def configure(self, name, cf): if cf.has_option(name, "port"): self.port = cf.getint(name, "port") if cf.has_option(name, "host"): self.host = cf.get(name, "host") if cf.has_option(name, "authenticators"): auth = cf.get(name, "authenticators").split(",") self.authenticators = [ServiceLoc(a.strip()) for a in auth] if cf.has_option(name, "publish"): pub = cf.get(name, "publish").split(",") self.services = [p.strip() for p in pub] if cf.has_option(name, "insecureLocalAccounts"): accts = cf.get(name, "insecureLocalAccounts").split(",") for acct in accts: usr, pword, groups = acct.split(":") self.localAccounts[usr] = pword, groups if cf.has_option(name, "interfaces"): parseInterfaceConfig(cf.get(name, "interfaces"), self) def startService(self): """ Start listening. """ Service.startService(self) p = portal.Portal(self, [self]) self.listener = reactor.listenTCP(self.port, pb.PBServerFactory(p)) #@UndefinedVariable # find the pb client and add a localAlias cfact = self.parent.connectionFactories.get("pb") if cfact is not None: cfact.localAliases.append(ServiceLoc("pb://%s:%d" % ( getHostIp(), self.port))) def stopService(self): """ Stop listening """ Service.stopService(self) return self.listener.stopListening() def getLocation(self): """ Returns a serviceloc that points to our location """ return ServiceLoc(protocol="pb", host=self.host, port=self.port) def getService(self, client, name): """ If we publish this service, return the PB interface to it """ if self.services is None or name not in self.services: raise ValueError("Service '%s' is not published here" % name) svc = self.parent.getServiceNamed(name) svcCls = ".".join([svc.__class__.__module__, svc.__class__.__name__]) return svcCls, svc.getInterface(client, "pb") def getServiceByInterface(self, client, interface): """ If we publish this service, return the PB interface to it """ icls = getClass(interface) svc = self.parent.getLocalService(icls) return svc.name, svc.getInterface(client, "pb") def requestAvatar(self, avatarID, mind, *interfaces): """ Create a client connection object for this avatar """ assert pb.IPerspective in interfaces, "Must talk PB to us." c = PBRemoteClient(self, mind) c.accountInfo = avatarID c.parent = self self.clients.append(c) self.connectionCount += 1 return pb.IPerspective, c, c.disconnected @inlineCallbacks def requestAvatarId(self, credentials): """ This is called when a user logs in and gives us a set of credentials. """ try: splitCreds = credentials.username.split(",") _user, authMethod = splitCreds[:2] if authMethod == "loginService": acctInfo = yield self._requestAvatarIdThroughLoginService( credentials, splitCreds) elif authMethod == "local": acctInfo = yield self._requestAvatarIdLocal( credentials, splitCreds) else: self.parent.log("Bad authMethod %s." % authMethod, logging.WARNING) raise UnauthorizedLogin("Invalid credentials.") except _DefGen_Return: raise except: deferr() raise UnauthorizedLogin("Invalid credentials.") returnValue(acctInfo) @inlineCallbacks def _requestAvatarIdThroughLoginService(self, credentials, splitCreds): """ Handles a login request which goes through a third party login service """ user = splitCreds[0] uid = splitCreds[2] service = yield self.parent.getOneService(self.authenticators) if not service.isLocal(): assert service.getLocation().creds is not None, ( "%s creds are none" % service.getLocation()) key = yield service.getSessionKey(*service.getLocation().creds) acctInfo = yield service.getLoginInfo(user, int(uid)) acctInfo["passwords"] = AES.new(key).decrypt( b64decode(acctInfo["passwords"])).split(",")[:2] else: localSession = LocalSession(user) if IAccountService.providedBy(service): acctInfo = yield service.getLoginInfo( localSession.getUsername(), localSession.getSessionID(), user, int(uid)) else: acctInfo = yield service.getLoginInfo( LocalSession(user), user, int(uid)) pw1, _pw2 = acctInfo["passwords"] # json can't handle tuples. if acctInfo.has_key("pcs"): acctInfo["pcs"] = [tuple(pcid) for pcid in acctInfo["pcs"]] if not credentials.checkPassword(pw1): self.parent.log("Bad password.", logging.WARNING) raise UnauthorizedLogin("Invalid credentials.") returnValue(acctInfo) def _requestAvatarIdLocal(self, credentials, splitCreds): """ Handle a locally authenticatable login request """ try: pword, groups = self.localAccounts[splitCreds[0]] except KeyError: raise UnauthorizedLogin("Invalid credentials.") if not credentials.checkPassword(pword): self.parent.log("Bad password.", logging.WARNING) raise UnauthorizedLogin("Invalid credentials.") return dict(username=splitCreds[0], pcs=[], groups=groups) def publish(self, serviceName): """ Start publishing a service """ assert serviceName not in self.services self.services.append(serviceName) def getStatistics(self): """ Return a dict with statistics """ return dict(connectionCount=str(self.connectionCount)) @requirePermissions("reference") def getInterface(self, _client, protocol): """ Hand out public interfaces """ return self.interfaces[protocol] class JSONRPCPage(rend.Page): """ This is a simple implementation of JSON RPC using HTTP To expose a method, you must preface the name with json_. The first argument will be the login details of the client. """ addSlash = True client = None def __init__(self, service, client=None): self.client = client self.service = service def renderHTTP(self, ctx): """ Parse the request and call the appropriate method. This is a fairly basic implementation of JSON RPC over HTTP. """ request = inevow.IRequest(ctx) if self.client is not None and not self.client.accountInfo.has_key( "session"): self.client.accountInfo["session"] = request.session.uid if not request.args.has_key("data"): if self.client is None: return "Anonymous Access" return "Login accepted." return self.handleRequest(request) def handleRequest(self, request): rid = None try: jreq = cjson.decode(request.args["data"][0]) rid = jreq["id"] method = jreq["method"] params = jreq["params"] args = params.get("args", []) kwargs = dict([(str(key), value) for key, value in params.get("kwargs", {}).iteritems()]) # print "JSON Request", method, params if self.client is None: self.client = AnonymousClient(request.session.uid) d = defer.maybeDeferred(getattr(self, "json_" + method), self.client, *args, **kwargs) except: err = [".".join([sys.exc_info()[0].__module__, sys.exc_info()[0].__name__]), str(sys.exc_info()[1]), traceback.format_tb(sys.exc_info()[2]) ] reply = dict(id=rid, result=None, error=err) return defer.succeed(cjson.encode(reply)) def done(result): reply = dict(result=result, id=rid, error=None) return cjson.encode(reply) def error(e): if isinstance(e.type, str): reply = dict(result=None, error=(str(e.type), str(e.value), str(e.getTraceback())), id=rid) else: reply = dict(result=None, error=(".".join([e.type.__module__, e.type.__name__]), str(e.value), str(e.getTraceback())), id=rid) e.printTraceback() # it's log, it's log! return cjson.encode(reply) d.addCallback(done) d.addErrback(error) return d class RPCRoot(rend.Page): """ Default root page """ docFactory = loaders.stan(["Anonymous Access"]) addSlash = True class BlankPage(rend.Page): """ A blank page to store children """ docFactory = loaders.stan(["Empty"]) # should return 404 addSlash = True class HttpServerView(pb.Viewable): """ This is a server that listens for JSON RPC over http(s) reqs """ def __init__(self, service): self.service = service def getProtocol(self): """ Return what protocol we implement """ return "pb" @checkServicePermissions("read") @viewed def view_getStatistics(self, client): """ Gets a dict of stats """ @checkServicePermissions("read") @viewed def view_getConfig(self, client): """ Gets a dict of config """ class HttpServer(Service, Securable): """ This is a server that listens for JSON RPC over http(s) reqs """ implements(portal.IRealm, ICredentialsChecker, IService) credentialInterfaces = (credentials.IUsernamePassword, credentials.IUsernameHashedPassword) authenticators = None port = None docRoot = None sslContext = None listener = None site = None protocol = "http" def __init__(self): Securable.__init__(self) self.published = [] self.anonymous = [] self.pages = {} self.anonPages = {} self.static = {} self.authenticators = [] self.interfaces = {} self.config = {} def configure(self, nm, cf): """ Configure this interface """ self.config = dict(cf.items(nm)) if cf.has_option(nm, "authenticators"): if cf.get(nm, "authenticators"): acts = cf.get(nm, "authenticators").split(",") for act in acts: self.authenticators.append(ServiceLoc(act)) if cf.has_option(nm, "port"): self.port = cf.getint(nm, "port") if cf.has_option(nm, "docRoot"): self.docRoot = cf.get(nm, "docRoot") if cf.has_option(nm, "ssl") and "on" in cf.get(nm, "ssl"): privKey = cf.get(nm, "privKey") pubKey = cf.get(nm, "pubKey") self.sslContext = ssl.DefaultOpenSSLContextFactory( privKey, pubKey) if cf.has_option(nm, "grantPermissions"): parsePermissionConfig(cf.get(nm, "grantPermissions"), self, True) if cf.has_option(nm, "denyPermissions"): parsePermissionConfig(cf.get(nm, "denyPermissions"), self, False) if cf.has_option(nm, "publish"): svcs = cf.get(nm, "publish").split(",") self.published = [s.strip() for s in svcs] def getClass(typ): cname = typ.split(".")[-1] module = ".".join(typ.split(".")[:-1]) return getattr(__import__(module, globals(), locals(), fromlist=[cname]), cname) def getArgs(_section): sect = dict(cf.items(n)).copy() del sect["location"] del sect["type"] return sect if cf.has_option(nm, "pages"): names = cf.get(nm, "pages").split(",") for n in names: n = n.strip() loc = cf.get(n, "location") cls = getClass(cf.get(n, "type")) sect = getArgs(n) self.setPage(loc, cls, False, self, **sect) if cf.has_option(nm, "enableWebEditor"): # TODO: Hack for overseer if cf.getboolean(nm, "enableWebEditor"): cls = getClass("mv3d.server.editor.WebRoot") self.setPage("webedit/", cls, False, self, templatedir="templates/editor") if cf.has_option(nm, "anonPages"): names = cf.get(nm, "anonPages").split(",") for n in names: n = n.strip() loc = cf.get(n, "location") cls = getClass(cf.get(n, "type")) sect = getArgs(n) self.setPage(loc, cls, True, self, **sect) if cf.has_option(nm, "static"): statics = cf.get(nm, "static").split(",") for s in statics: loc, fname = s.strip().split(":") self.static[loc] = fname if cf.has_option(nm, "allowAnonymous"): anon = cf.get(nm, "allowAnonymous").split(",") self.anonymous = [s.strip() for s in anon] if cf.has_option(nm, "interfaces"): parseInterfaceConfig(cf.get(nm, "interfaces"), self) def startService(self): """ Start up the web service and register the children """ portl = portal.Portal(self) portl.registerChecker(self, credentials.IUsernamePassword) portl.registerChecker(checkers.AllowAnonymousAccess(), credentials.IAnonymous) self.site = appserver.NevowSite(guard.SessionWrapper(portl)) if self.sslContext is not None: self.protocol = "https" self.listener = reactor.listenSSL(#@UndefinedVariable self.port, self.site, self.sslContext) else: self.listener = reactor.listenTCP(self.port, self.site) #@UndefinedVariable def stopService(self): """ Stop the web service and kill all sessions """ if self.site is not None: for s in self.site.resource.sessions.values(): s.expire() if self.listener is not None: d = self.listener.stopListening() self.listener = None self.site = None return d def setPage(self, location, pageClass, anonymous=False, *a, **kw): """ Set up a page at location. For example, if location is /helloworld/, page will become a child of root named helloworld. / is a special page that will become the root page. """ if anonymous: self.anonPages[location] = (pageClass, a, kw) else: self.pages[location] = (pageClass, a, kw) def putChildAt(self, root, loc, child): """ Puts a child at a given location creating blank pages previous to it if needed """ child.realm = self loc = filter(lambda x: x != "", loc.split("/")) def checkLoc(parent, nm): if parent.children is not None and parent.children.has_key(nm): oldChild = parent.children[nm] if not isinstance(oldChild.children, dict): oldChild.children = {} if not isinstance(child.children, dict): child.children = {} child.children.update(oldChild.children) if len(loc) == 1: checkLoc(root, loc[0]) root.putChild(loc[0], child) else: cur = root for l in loc[:-1]: if not l in cur.children: cur.putChild(l, BlankPage()) cur = cur.children[l] checkLoc(cur, loc[-1]) cur.putChild(loc[-1], child) def buildAnonymous(self, root): """ Build the anonymous side of the site """ for s in self.anonymous: svc = self.parent.getServiceNamed(s) try: i = svc.getInterface(anonymous, self.protocol) self.putChildAt(root, s, i) except AccessDenied: pass for loc, data in self.anonPages.items(): page, a, kw = data if loc != "/": self.putChildAt(root, loc, page(None, *a, **kw)) for loc, fname in self.static.items(): self.putChildAt(root, loc, static.File(fname)) def createRoot(self, client=None): """ Create a document root by adding all available services. We ignore AccessDenied errors here because we just won't allow access to those interfaces. """ if client is not None and self.pages.has_key("/"): p, a, kw = self.pages["/"] root = p(client, *a, **kw) elif self.anonPages.has_key("/"): p, a, kw = self.anonPages["/"] root = p(client, *a, **kw) else: root = RPCRoot() root.realm = self if client is None: self.buildAnonymous(root) else: # root.docFactory = loaders.stan(["Login accepted."]) self.buildAnonymous(root) for s in self.published: svc = self.parent.getServiceNamed(s) try: i = svc.getInterface(client, self.protocol) self.putChildAt(root, s, i) except AccessDenied: pass for loc, data in self.pages.items(): page, a, kw = data if loc != "/": self.putChildAt(root, loc, page(client, *a, **kw)) return root def requestAvatar(self, avatarId, _mind, *interfaces): for iface in interfaces: if iface is inevow.IResource: if avatarId is checkers.ANONYMOUS: resc = self.createRoot() return (inevow.IResource, resc, lambda: None) resc = self.createRoot(avatarId) return (inevow.IResource, resc, lambda: self.logout(avatarId)) raise NotImplementedError("Can't support that interface.") def requestAvatarId(self, credentials): """ This is called when a user logs in and gives us a set of credentials. """ d = self.parent.getOneService(self.authenticators) return d.addCallback(lambda i: i.authenticate(credentials.username, md5.new(credentials.username + credentials.password).hexdigest())) def logout(self, client): """ Called when a user logs out of the web interface """ if not client.has_key("session"): return d = self.parent.getOneService(self.authenticators) return d.addCallback(lambda i: i.logoutUser(client["session"], client["username"])) def getStatistics(self): """ Get a dict of statistics """ return dict() def getConfig(self): """ Returns a dict of config items """ return self.config @requirePermissions("reference") def getInterface(self, _client, protocol): """ Hand out public interfaces """ return self.interfaces[protocol]