File indexing completed on 2024-04-14 03:59:13
0001 # -*- coding: utf-8 -*- 0002 0003 """ 0004 Copyright (C) 2009-2016 Wolfgang Rohdewald <wolfgang@rohdewald.de> 0005 0006 SPDX-License-Identifier: GPL-2.0 0007 0008 0009 The DBPasswordChecker is based on an example from the book 0010 Twisted Network Programming Essentials by Abe Fettig, 2006 0011 O'Reilly Media, Inc., ISBN 0-596-10032-9 0012 """ 0013 0014 # pylint: disable=wrong-import-order, wrong-import-position 0015 0016 import sys 0017 import os 0018 import logging 0019 import datetime 0020 0021 from zope.interface import implementer 0022 0023 0024 def cleanExit(*unusedArgs): # pylint: disable=unused-argument 0025 """we want to cleanly close sqlite3 files""" 0026 if Debug.quit: 0027 logDebug('cleanExit') 0028 if Options.socket and os.name != 'nt': 0029 if os.path.exists(Options.socket): 0030 os.remove(Options.socket) 0031 try: 0032 if Internal.db: 0033 Internal.db.close() 0034 # setting to None does not call close(), do we 0035 # need close? 0036 logging.shutdown() 0037 os._exit(0) # pylint: disable=protected-access 0038 except NameError: 0039 logging.shutdown() 0040 try: 0041 reactor.stop() 0042 except NameError: 0043 sys.exit(0) 0044 except ReactorNotRunning: 0045 pass 0046 0047 from common import handleSignals 0048 handleSignals(cleanExit) 0049 0050 from common import Options, Internal, Debug 0051 Internal.isServer = True 0052 Internal.logPrefix = 'S' 0053 0054 from twisted.spread import pb 0055 from twisted.internet import error 0056 from twisted.internet.defer import maybeDeferred, fail, succeed 0057 from twisted.cred import checkers, portal, credentials, error as credError 0058 from twisted.internet import reactor 0059 from twisted.internet.error import ReactorNotRunning 0060 reactor.addSystemEventTrigger('before', 'shutdown', cleanExit) 0061 Internal.reactor = reactor 0062 0063 from player import Players 0064 from query import Query, initDb 0065 from log import logDebug, logWarning, logError, logInfo, SERVERMARK 0066 from mi18n import i18n, i18nE 0067 from util import elapsedSince 0068 from message import Message, ChatMessage 0069 from deferredutil import DeferredBlock 0070 from rule import Ruleset 0071 from servercommon import srvError, srvMessage 0072 from user import User 0073 from servertable import ServerTable, ServerGame 0074 0075 0076 @implementer(checkers.ICredentialsChecker) 0077 class DBPasswordChecker: 0078 0079 """checks against our sqlite3 databases""" 0080 credentialInterfaces = (credentials.IUsernamePassword, 0081 credentials.IUsernameHashedPassword) 0082 0083 def requestAvatarId(self, cred): # pylint: disable=no-self-use 0084 """get user id from database""" 0085 cred.username = cred.username.decode('utf-8') 0086 args = cred.username.split(SERVERMARK) 0087 if len(args) > 1: 0088 if args[0] == 'adduser': 0089 cred.username = args[1] 0090 password = args[2] 0091 query = Query( 0092 'insert or ignore into player(name,password) values(?,?)', 0093 (cred.username, 0094 password)) 0095 elif args[1] == 'deluser': 0096 pass 0097 query = Query( 0098 'select id, password from player where name=?', (cred.username,)) 0099 if not query.records: 0100 template = 'Wrong username: %1' 0101 if Debug.connections: 0102 logDebug(i18n(template, cred.username)) 0103 return fail(credError.UnauthorizedLogin(srvMessage(template, cred.username))) 0104 userid, password = query.records[0] 0105 defer1 = maybeDeferred(cred.checkPassword, password.encode('utf-8')) 0106 defer1.addCallback(DBPasswordChecker._checkedPassword, userid) 0107 return defer1 0108 0109 @staticmethod 0110 def _checkedPassword(matched, userid): 0111 """after the password has been checked""" 0112 if not matched: 0113 return fail(credError.UnauthorizedLogin(srvMessage(i18nE('Wrong password')))) 0114 return userid 0115 0116 0117 class MJServer: 0118 0119 """the real mah jongg server""" 0120 0121 def __init__(self): 0122 self.tables = {} 0123 self.srvUsers = list() 0124 Players.load() 0125 self.lastPing = datetime.datetime.now() 0126 self.checkPings() 0127 0128 def chat(self, chatString): 0129 """a client sent us a chat message""" 0130 chatLine = ChatMessage(chatString) 0131 if Debug.chat: 0132 logDebug('server got chat message %s' % chatLine) 0133 self.tables[chatLine.tableid].sendChatMessage(chatLine) 0134 0135 def login(self, user): 0136 """accept a new user""" 0137 if user not in self.srvUsers: 0138 self.srvUsers.append(user) 0139 self.loadSuspendedTables(user) 0140 0141 def callRemote(self, user, *args, **kwargs): 0142 """if we still have a connection, call remote, otherwise clean up""" 0143 if user.mind: 0144 try: 0145 args2, kwargs2 = Message.jellyAll(args, kwargs) 0146 return user.mind.callRemote(*args2, **kwargs2).addErrback(MJServer.ignoreLostConnection) 0147 except (pb.DeadReferenceError, pb.PBConnectionLost): 0148 user.mind = None 0149 self.logout(user) 0150 return None 0151 0152 @staticmethod 0153 def __stopAfterLastDisconnect(): 0154 """as the name says""" 0155 if Options.socket and not Options.continueServer: 0156 try: 0157 reactor.stop() 0158 if Debug.connections: 0159 logDebug('local server terminates from %s. Reason: last client disconnected' % ( 0160 Options.socket)) 0161 except ReactorNotRunning: 0162 pass 0163 0164 def checkPings(self): 0165 """are all clients still alive? If not log them out""" 0166 since = elapsedSince(self.lastPing) 0167 if self.srvUsers and since > 30: 0168 if Debug.quit: 0169 logDebug('no ping since {} seconds but we still have users:{}'.format( 0170 elapsedSince(self.lastPing), self.srvUsers)) 0171 if not self.srvUsers and since > 30: 0172 # no user at all since 30 seconds, but we did already have a user 0173 self.__stopAfterLastDisconnect() 0174 for user in self.srvUsers: 0175 if elapsedSince(user.lastPing) > 60: 0176 logInfo( 0177 'No messages from %s since 60 seconds, clearing connection now' % 0178 user.name) 0179 user.mind = None 0180 self.logout(user) 0181 reactor.callLater(10, self.checkPings) 0182 0183 @staticmethod 0184 def ignoreLostConnection(failure): 0185 """if the client went away correctly, do not dump error messages on stdout.""" 0186 msg = failure.getErrorMessage() 0187 if 'twisted.internet.error.ConnectionDone' not in msg: 0188 logError(msg) 0189 failure.trap(pb.PBConnectionLost) 0190 0191 def sendTables(self, user, tables=None): 0192 """send tables to user. If tables is None, he gets all new tables and those 0193 suspended tables he was sitting on""" 0194 if tables is None: 0195 tables = [ 0196 x for x in self.tables.values() 0197 if not x.running and (not x.suspendedAt or x.hasName(user.name))] 0198 if tables: 0199 data = [x.asSimpleList() for x in tables] 0200 if Debug.table: 0201 logDebug( 0202 'sending %d tables to %s: %s' % 0203 (len(tables), user.name, data)) 0204 return self.callRemote(user, 'newTables', data) 0205 return succeed([]) 0206 0207 def _lookupTable(self, tableid): 0208 """return table by id or raise exception""" 0209 if tableid not in self.tables: 0210 raise srvError( 0211 pb.Error, 0212 i18nE('table with id <numid>%1</numid> not found'), 0213 tableid) 0214 return self.tables[tableid] 0215 0216 def generateTableId(self): 0217 """generates a new table id: the first free one""" 0218 usedIds = set(self.tables or [0]) 0219 availableIds = set(x for x in range(1, 2 + max(usedIds))) 0220 return min(availableIds - usedIds) 0221 0222 def newTable(self, user, ruleset, playOpen, 0223 autoPlay, wantedGame, tableId=None): 0224 """user creates new table and joins it""" 0225 def gotRuleset(ruleset): 0226 """now we have the full ruleset definition from the client""" 0227 Ruleset.cached( 0228 ruleset).save() # make it known to the cache and save in db 0229 if tableId in self.tables: 0230 return fail(srvError(pb.Error, 0231 'You want a new table with id=%d but that id is already used for table %s' % ( 0232 tableId, self.tables[tableId]))) 0233 if Ruleset.hashIsKnown(ruleset): 0234 return self.__newTable(None, user, ruleset, playOpen, autoPlay, wantedGame, tableId) 0235 return self.callRemote(user, 'needRuleset', ruleset).addCallback( 0236 gotRuleset).addCallback( 0237 self.__newTable, user, ruleset, playOpen, autoPlay, wantedGame, tableId) 0238 0239 def __newTable(self, unused, user, ruleset, 0240 playOpen, autoPlay, wantedGame, tableId=None): 0241 """now we know the ruleset""" 0242 def sent(unused): 0243 """new table sent to user who created it""" 0244 return table.tableid 0245 table = ServerTable( 0246 self, 0247 user, 0248 ruleset, 0249 None, 0250 playOpen, 0251 autoPlay, 0252 wantedGame, 0253 tableId) 0254 result = None 0255 for srvUser in self.srvUsers: 0256 deferred = self.sendTables(srvUser, [table]) 0257 if user == srvUser: 0258 result = deferred 0259 deferred.addCallback(sent) 0260 assert result 0261 return result 0262 0263 def needRulesets(self, rulesetHashes): 0264 """the client wants those full rulesets""" 0265 result = [] 0266 for table in self.tables.values(): 0267 if table.ruleset.hash in rulesetHashes: 0268 result.append(table.ruleset.toList()) 0269 return result 0270 0271 def joinTable(self, user, tableid): 0272 """user joins table""" 0273 table = self._lookupTable(tableid) 0274 table.addUser(user) 0275 block = DeferredBlock(table) 0276 block.tell( 0277 None, 0278 self.srvUsers, 0279 Message.TableChanged, 0280 source=table.asSimpleList()) 0281 if len(table.users) == table.maxSeats(): 0282 if Debug.table: 0283 logDebug('Table %s: All seats taken, starting' % table) 0284 0285 def startTable(unused): 0286 """now all players know about our join""" 0287 table.readyForGameStart(table.owner) 0288 block.callback(startTable) 0289 else: 0290 block.callback(False) 0291 return True 0292 0293 def tablesWith(self, user): 0294 """table ids with user, except table 'without'""" 0295 return [x.tableid for x in self.tables.values() if user in x.users] 0296 0297 def leaveTable(self, user, tableid, message, *args): 0298 """user leaves table. If no human user is left on a new table, remove it""" 0299 if tableid in self.tables: 0300 table = self.tables[tableid] 0301 if user in table.users: 0302 if len(table.users) == 1 and not table.suspendedAt: 0303 # silent: do not tell the user who left the table that he 0304 # did 0305 self.removeTable(table, 'silent', message, *args) 0306 else: 0307 table.delUser(user) 0308 if self.srvUsers: 0309 block = DeferredBlock(table) 0310 block.tell( 0311 None, 0312 self.srvUsers, 0313 Message.TableChanged, 0314 source=table.asSimpleList()) 0315 block.callback(False) 0316 return True 0317 0318 def startGame(self, user, tableid): 0319 """try to start the game""" 0320 return self._lookupTable(tableid).readyForGameStart(user) 0321 0322 def removeTable(self, table, reason, message, *args): 0323 """remove a table""" 0324 assert reason in ('silent', 'tableRemoved', 'gameOver', 'abort') 0325 # HumanClient implements methods remote_tableRemoved etc. 0326 message = message or '' 0327 if Debug.connections or reason == 'abort': 0328 logDebug( 0329 '%s%s ' % (('%s:' % table.game.seed) if table.game else '', 0330 i18n(message, *args)), withGamePrefix=None) 0331 if table.tableid in self.tables: 0332 del self.tables[table.tableid] 0333 if reason == 'silent': 0334 tellUsers = [] 0335 else: 0336 tellUsers = table.users if table.running else self.srvUsers 0337 for user in tellUsers: 0338 # this may in turn call removeTable again! 0339 self.callRemote(user, reason, table.tableid, message, *args) 0340 for user in table.users: 0341 table.delUser(user) 0342 if Debug.table: 0343 logDebug( 0344 'removing table %d: %s %s' % 0345 (table.tableid, i18n(message, *args), reason)) 0346 if table.game: 0347 table.game.close() 0348 0349 def logout(self, user): 0350 """remove user from all tables""" 0351 if user not in self.srvUsers: 0352 return 0353 self.srvUsers.remove(user) 0354 for tableid in self.tablesWith(user): 0355 self.leaveTable( 0356 user, 0357 tableid, 0358 i18nE('Player %1 has logged out'), 0359 user.name) 0360 # wait a moment. We want the leaveTable message to arrive everywhere before 0361 # we say serverDisconnects. Sometimes the order was reversed. 0362 reactor.callLater(1, self.__logout2, user) 0363 0364 def __logout2(self, user): 0365 """now the leaveTable message had a good chance to get to the clients first""" 0366 self.callRemote(user, 'serverDisconnects') 0367 user.mind = None 0368 for block in DeferredBlock.blocks: 0369 for request in block.requests: 0370 if request.user == user: 0371 request.answer = Message.Abort 0372 0373 def loadSuspendedTables(self, user): 0374 """loads all yet unloaded suspended tables where this 0375 user is participating. We do not unload them if the 0376 user logs out, there are filters anyway returning only 0377 the suspended games for a certain user. 0378 Never load old autoplay games.""" 0379 query = Query("select distinct g.id, g.starttime, " 0380 "g.seed, " 0381 "ruleset, s.scoretime " 0382 "from game g, player p0, score s," 0383 "player p1, player p2, player p3 " 0384 "where autoplay=0 " 0385 " and p0.id=g.p0 and p1.id=g.p1 " 0386 " and p2.id=g.p2 and p3.id=g.p3 " 0387 " and (p0.name=? or p1.name=? or p2.name=? or p3.name=?) " 0388 " and s.game=g.id" 0389 " and g.endtime is null" 0390 " and exists(select 1 from ruleset where ruleset.id=g.ruleset)" 0391 " and exists(select 1 from score where game=g.id)" 0392 " and s.scoretime = (select max(scoretime) from score where game=g.id) limit 10", 0393 (user.name, user.name, user.name, user.name)) 0394 for gameid, _, seed, ruleset, suspendTime in query.records: 0395 if gameid not in (x.game.gameid for x in self.tables.values() if x.game): 0396 table = ServerTable( 0397 self, None, ruleset, suspendTime, playOpen=False, 0398 autoPlay=False, wantedGame=str(seed)) 0399 table.game = ServerGame.loadFromDB(gameid) 0400 0401 0402 @implementer(portal.IRealm) 0403 class MJRealm: 0404 0405 """connects mind and server""" 0406 0407 def __init__(self): 0408 self.server = None 0409 0410 def requestAvatar(self, avatarId, mind, *interfaces): 0411 """as the tutorials do...""" 0412 if pb.IPerspective not in interfaces: 0413 raise NotImplementedError("No supported avatar interface") 0414 avatar = User(avatarId) 0415 avatar.server = self.server 0416 avatar.attached(mind) 0417 if Debug.connections: 0418 logDebug('Connection from %s ' % avatar.source()) 0419 return pb.IPerspective, avatar, lambda a=avatar: a.detached(mind) 0420 0421 0422 def parseArgs(): 0423 """as the name says""" 0424 from optparse import OptionParser 0425 parser = OptionParser() 0426 defaultPort = Internal.defaultPort 0427 parser.add_option('', '--port', dest='port', 0428 help=i18n( 0429 'the server will listen on PORT (%d)' % 0430 defaultPort), 0431 type=int, default=defaultPort) 0432 parser.add_option('', '--socket', dest='socket', 0433 help=i18n('the server will listen on SOCKET'), default=None) 0434 parser.add_option( 0435 '', 0436 '--db', 0437 dest='dbpath', 0438 help=i18n('name of the database'), 0439 default=None) 0440 parser.add_option( 0441 '', '--continue', dest='continueServer', action='store_true', 0442 help=i18n('do not terminate local game server after last client disconnects'), default=False) 0443 parser.add_option('', '--debug', dest='debug', 0444 help=Debug.help()) 0445 (options, args) = parser.parse_args() 0446 if args and ''.join(args): 0447 logWarning(i18n('unrecognized arguments:%1', ' '.join(args))) 0448 sys.exit(2) 0449 Options.continueServer |= options.continueServer 0450 if options.dbpath: 0451 Options.dbPath = os.path.expanduser(options.dbpath) 0452 if options.socket: 0453 Options.socket = options.socket 0454 Debug.setOptions(options.debug) 0455 Options.fixed = True # may not be changed anymore 0456 del parser # makes Debug.gc quieter 0457 return options 0458 0459 0460 def kajonggServer(): 0461 """start the server""" 0462 # pylint: disable=too-many-branches 0463 options = parseArgs() 0464 if not initDb(): 0465 sys.exit(1) 0466 realm = MJRealm() 0467 realm.server = MJServer() 0468 kajonggPortal = portal.Portal(realm, [DBPasswordChecker()]) 0469 import predefined 0470 predefined.load() 0471 try: 0472 if Options.socket: 0473 # we do not want tracebacks to go from server to client, 0474 # please check on the server side instead 0475 factory = pb.PBServerFactory(kajonggPortal, unsafeTracebacks=False) 0476 if os.name == 'nt': 0477 if Debug.connections: 0478 logDebug( 0479 'local server listening on 127.0.0.1 port %d' % 0480 options.port) 0481 reactor.listenTCP(options.port, factory, interface='127.0.0.1') 0482 else: 0483 if Debug.connections: 0484 logDebug( 0485 'local server listening on UNIX socket %s' % 0486 Options.socket) 0487 reactor.listenUNIX(Options.socket, factory) 0488 else: 0489 if Debug.connections: 0490 logDebug('server listening on port %d' % options.port) 0491 reactor.listenTCP(options.port, pb.PBServerFactory(kajonggPortal)) 0492 except error.CannotListenError as errObj: 0493 logWarning(errObj) 0494 sys.exit(1) 0495 else: 0496 reactor.run() 0497 0498 0499 def profileMe(): 0500 """where do we lose time?""" 0501 import cProfile 0502 cProfile.run('kajonggServer()', 'prof') 0503 import pstats 0504 statistics = pstats.Stats('prof') 0505 statistics.sort_stats('cumulative') 0506 statistics.print_stats(40)