mirror of
https://github.com/barronwaffles/dwc_network_server_emulator.git
synced 2026-08-25 03:55:53 -05:00
Significantly changed the layout of the server.
All parts of the server can now be run on their own or used as a class. master_server.py will start running all of the servers at once.
This commit is contained in:
@@ -142,7 +142,7 @@ class GamespyDatabase(object):
|
||||
|
||||
return users
|
||||
|
||||
def update_profile(self, session_key, fields):
|
||||
def update_profile(self, session_key, field):
|
||||
profileid = self.get_profileid_from_session_key(session_key)
|
||||
|
||||
if profileid != -1:
|
||||
@@ -151,10 +151,9 @@ class GamespyDatabase(object):
|
||||
# TODO: Optimize this so it's done all in one update.
|
||||
# FIXME: Possible security issue due to embedding an unsanitized string directly into the statement.
|
||||
c = self.conn.cursor()
|
||||
for field in fields:
|
||||
print "UPDATE users SET %s = %s WHERE profileid = %s" % (field[0], field[1], profileid)
|
||||
c.execute("UPDATE users SET %s = ? WHERE profileid = ?" % (field[0]), [field[1], profileid])
|
||||
self.conn.commit()
|
||||
print "UPDATE users SET %s = %s WHERE profileid = %s" % (field[0], field[1], profileid)
|
||||
c.execute("UPDATE users SET %s = ? WHERE profileid = ?" % (field[0]), [field[1], profileid])
|
||||
self.conn.commit()
|
||||
|
||||
# Session functions
|
||||
# TODO: Cache session keys so we don't have to query the database every time we get a profile id.
|
||||
|
||||
387
gamespy_backend_server.py
Normal file
387
gamespy_backend_server.py
Normal file
@@ -0,0 +1,387 @@
|
||||
# Master server list server
|
||||
#
|
||||
# Basic idea:
|
||||
# The server listing does not need to be persistent, and it must be easily searchable for any unknown parameters.
|
||||
# So instead of using a SQL database, I've opted to create a server list database server which communicates between the
|
||||
# server browser and the qr server. The server list database will be stored in dictionaries as to allow dynamic columns
|
||||
# that can be easily searched. The main reason for this configuration is because it cannot be guaranteed what data
|
||||
# a game's server will required. For example, in addition to the common fields such as publicip, numplayers, dwc_pid, etc,
|
||||
# Lost Magic also uses fields such as LMname, LMsecN, LMrating, LMbtmode, and LMversion.
|
||||
#
|
||||
# It would be possible to create game-specific databases but this would be more of a hassle and less universal. It would
|
||||
# also be possible pickle a dictionary containing all of the fields and store it in a SQL database instead, but that
|
||||
# would require unpickling every server each time you want to match search queries which would cause overhead if there
|
||||
# are a lot of running servers. One trade off here is that we'll be using more memory by storing each server as a
|
||||
# dictionary in the memory instead of storing it in a SQL database.
|
||||
#
|
||||
# qr_server and server_browser both will act as clients to gs_server_database.
|
||||
# qr_server will send an add and/or delete to add or remove servers from the server list.
|
||||
# server_browser will send a request with the game name followed by optional search parameters to get a list of servers.
|
||||
|
||||
import logging
|
||||
|
||||
from multiprocessing.managers import BaseManager
|
||||
from multiprocessing import freeze_support
|
||||
import other.utils as utils
|
||||
|
||||
class TokenType:
|
||||
UNKNOWN = 0
|
||||
FIELD = 1
|
||||
STRING = 2
|
||||
NUMBER = 3
|
||||
TOKEN = 4
|
||||
|
||||
logger = utils.create_logger("GamespyBackendServer", "gamespy_backend_server.log", -1)
|
||||
|
||||
class GameSpyServerDatabase(BaseManager):
|
||||
pass
|
||||
|
||||
class GameSpyBackendServer(object):
|
||||
def __init__(self):
|
||||
self.server_list = {}
|
||||
|
||||
GameSpyServerDatabase.register("get_server_list", callable=lambda:self.server_list)
|
||||
GameSpyServerDatabase.register("find_servers", callable=self.find_servers)
|
||||
GameSpyServerDatabase.register("find_server_by_address", callable=self.find_server_by_address)
|
||||
GameSpyServerDatabase.register("update_server_list", callable=self.update_server_list)
|
||||
GameSpyServerDatabase.register("delete_server", callable=self.delete_server)
|
||||
|
||||
def start(self):
|
||||
address = ("127.0.0.1", 27500)
|
||||
password = ""
|
||||
|
||||
logger.log(logging.INFO, "Started server on %s:%d..." % (address[0], address[1]))
|
||||
|
||||
manager = GameSpyServerDatabase(address = address, authkey = password)
|
||||
server = manager.get_server()
|
||||
server.serve_forever()
|
||||
|
||||
def get_token(self, filters, i):
|
||||
# Complex example from Dungeon Explorer: Warriors of Ancient Arts
|
||||
# dwc_mver = 3 and dwc_pid != 474890913 and maxplayers = 2 and numplayers < 2 and dwc_mtype = 0 and dwc_mresv != dwc_pid and (MatchType='english')
|
||||
#
|
||||
# Even more complex example from Phantasy Star Zero:
|
||||
# dwc_mver = 3 and dwc_pid != 4 and maxplayers = 3 and numplayers < 3 and dwc_mtype = 0 and dwc_mresv != dwc_pid and (((20=auth)AND((1&mskdif)=mskdif)AND((14&mskstg)=mskstg)))
|
||||
#
|
||||
# Digging into a few DS games, and these hardcoded search queries seem to be consistent between them:
|
||||
# %s = %d and %s != %u and maxplayers = %d and numplayers < %d and %s = %d and %s != %s
|
||||
# %s and (%s)
|
||||
# %s = %u
|
||||
#
|
||||
# It does not look like OR commands are (at least by default) supported, so for now they won't be implemented.
|
||||
#
|
||||
# Things that have been implemented:
|
||||
# - and operator (assuming all commands are linked by ANDs)
|
||||
# - integer comparisons
|
||||
# - string literals comparisons
|
||||
# - comparison between two fields
|
||||
# - comparison operators (<, >, =, !=, <=, >=)
|
||||
# - bitwise and operator (&)
|
||||
#
|
||||
# Things that won't be supported for now unless required:
|
||||
# - or operator
|
||||
# - grouping of operators, e.g.: (x or y) and (y or z)
|
||||
start = i
|
||||
special_chars = "_"
|
||||
|
||||
token_type = TokenType.UNKNOWN
|
||||
|
||||
# Skip whitespace
|
||||
while i < len(filters) and filters[i].isspace():
|
||||
i += 1
|
||||
start += 1
|
||||
|
||||
if i < len(filters):
|
||||
if filters[i] == "(" or filters[i] == ")":
|
||||
i += 1
|
||||
token_type = TokenType.TOKEN
|
||||
|
||||
elif filters[i] == "&":
|
||||
i += 1
|
||||
token_type = TokenType.TOKEN
|
||||
|
||||
elif filters[i] == "=":
|
||||
i += 1
|
||||
token_type = TokenType.TOKEN
|
||||
|
||||
elif filters[i] == ">" or filters[i] == "<":
|
||||
i += 1
|
||||
token_type = TokenType.TOKEN
|
||||
|
||||
if i + 1 < len(filters) and filters[i+1] == "=":
|
||||
# >= or <=
|
||||
i += 1
|
||||
|
||||
elif i + 1 < len(filters) and filters[i] == "!" and filters[i + 1] == "=":
|
||||
i += 2
|
||||
token_type = TokenType.TOKEN
|
||||
|
||||
elif filters[i] == "'":
|
||||
# String literal
|
||||
token_type = TokenType.STRING
|
||||
|
||||
i += 1 # Skip quotation mark
|
||||
while i < len(filters) and filters[i] != "'":
|
||||
i += 1
|
||||
|
||||
if i < len(filters) and filters[i] == "'":
|
||||
i += 1 # Skip quotation mark
|
||||
|
||||
elif filters[i] == "\"":
|
||||
# I don't know if it's in the spec or not, but I added "" string literals as well just in case.
|
||||
token_type = TokenType.STRING
|
||||
|
||||
i += 1 # Skip quotation mark
|
||||
while i < len(filters) and filters[i] != "\"":
|
||||
i += 1
|
||||
|
||||
if i < len(filters) and filters[i] == "\"":
|
||||
i += 1 # Skip quotation mark
|
||||
|
||||
elif filters[i].isalnum() or filters[i] in special_chars:
|
||||
# Whole numbers or words
|
||||
if filters[i].isdigit():
|
||||
token_type = TokenType.NUMBER
|
||||
if filters[i].isalpha():
|
||||
token_type = TokenType.FIELD
|
||||
|
||||
while i < len(filters) and (filters[i].isalnum() or filters[i] in special_chars) and filters[i] not in "!=>< ":
|
||||
i += 1
|
||||
|
||||
if token_type == TokenType.STRING:
|
||||
token = filters[start + 1:i - 1]
|
||||
else:
|
||||
token = filters[start:i]
|
||||
|
||||
if token_type == TokenType.NUMBER:
|
||||
token = int(token)
|
||||
|
||||
return token, i, token_type
|
||||
|
||||
def match(self, filters, i, search, case_insensitive=False):
|
||||
start = i
|
||||
found_match = False
|
||||
|
||||
# Get the next token
|
||||
token, i, _ = self.get_token(filters, i)
|
||||
|
||||
if case_insensitive == True:
|
||||
token = token.lower()
|
||||
search = search.lower()
|
||||
|
||||
# If the token isn't the same as what we're searching for, don't move forward.
|
||||
if token != search:
|
||||
i = start
|
||||
else:
|
||||
found_match = True
|
||||
|
||||
return found_match, i
|
||||
|
||||
def parse_filter(self, filters):
|
||||
ops = []
|
||||
values = []
|
||||
i = 0
|
||||
found_match = True
|
||||
filter_count = 0
|
||||
|
||||
# Continue while there's a connecting "and".
|
||||
while found_match and i < len(filters):
|
||||
found_match_bracket, i = self.match(filters, i, "(")
|
||||
|
||||
if found_match_bracket:
|
||||
a, b, filters, f = self.parse_filter(filters[i:])
|
||||
found_closing_match_bracket, i = self.match(filters, i, ")")
|
||||
found_match = found_closing_match_bracket
|
||||
a.reverse()
|
||||
b.reverse()
|
||||
ops = a + ops
|
||||
values = b + values
|
||||
filter_count += f
|
||||
|
||||
else:
|
||||
l, i, token_type = self.get_token(filters, i)
|
||||
|
||||
if token_type == TokenType.FIELD and l.lower() == "and":
|
||||
filter_count += 1
|
||||
continue
|
||||
|
||||
elif l == "(" or l == ")":
|
||||
continue
|
||||
|
||||
if token_type == TokenType.TOKEN:
|
||||
ops.append(l)
|
||||
else:
|
||||
values.append({'value': l, 'type': token_type})
|
||||
|
||||
return ops, values, filters[i:], filter_count
|
||||
|
||||
def find_servers(self, gameid, filters, fields, max_count):
|
||||
servers = []
|
||||
|
||||
if gameid in self.server_list:
|
||||
ops_parsed, values_parsed, _, filter_count = self.parse_filter(filters)
|
||||
|
||||
# In the case that there were no "AND" commands, check to make sure there was at least one other command
|
||||
if len(ops_parsed) > 0:
|
||||
filter_count += 1
|
||||
|
||||
if max_count <= 0:
|
||||
max_count = 1
|
||||
|
||||
# Generate a list of servers that match the given criteria.
|
||||
for server in self.server_list[gameid]:
|
||||
ops = ops_parsed
|
||||
values = values_parsed
|
||||
|
||||
if len(servers) > max_count and max_count != -1:
|
||||
break
|
||||
|
||||
matched_filters = 0
|
||||
while len(ops) > 0:
|
||||
op = ops.pop()
|
||||
r = None
|
||||
l = None
|
||||
|
||||
if len(values) != 0:
|
||||
r = values.pop()
|
||||
if len(values) != 0:
|
||||
l = values.pop()
|
||||
|
||||
if r == None or l == None:
|
||||
break
|
||||
|
||||
lval = l['value']
|
||||
rval = r['value']
|
||||
|
||||
# If the left value is a field name and it's in the server variables, get its value.
|
||||
if l['type'] == TokenType.FIELD and l['value'] in server:
|
||||
lval = server[l['value']]
|
||||
_, _, l['type'] = self.get_token(lval, 0)
|
||||
|
||||
# If the value is a number then convert it to an integer for proper integer comparison.
|
||||
if l['type'] == TokenType.NUMBER:
|
||||
lval = int(lval)
|
||||
|
||||
# If the right value is a field name and it's in the server variables, get its value.
|
||||
if r['type'] == TokenType.FIELD and r['value'] in server:
|
||||
rval = server[r['value']]
|
||||
_, _, r['type'] = self.get_token(rval, 0)
|
||||
|
||||
# If the value is a number then convert it to an integer for proper integer comparison.
|
||||
if r['type'] == TokenType.NUMBER:
|
||||
rval = int(rval)
|
||||
|
||||
match = False
|
||||
if op == "=" and lval == rval:
|
||||
match = True
|
||||
elif op == "!=" and lval != rval:
|
||||
match = True
|
||||
elif op == "<" and lval < rval:
|
||||
match = True
|
||||
elif op == ">" and lval > rval:
|
||||
match = True
|
||||
elif op == ">=" and lval >= rval:
|
||||
match = True
|
||||
elif op == "<=" and lval <= rval:
|
||||
match = True
|
||||
elif op == "&":
|
||||
values.append({ 'value': int(lval & rval), 'type': TokenType.NUMBER})
|
||||
|
||||
if match == True:
|
||||
#print "Matched: %s %s %s" % (l['value'], op, r['value'])
|
||||
matched_filters += 1
|
||||
elif op != "&": # & doesn't need to be matched, so don't display a message for it
|
||||
#print "Not matched: %s %s %s" % (l['value'], op, r['value'])
|
||||
pass
|
||||
|
||||
#print "Matched %d/%d" % (matched_filters, filter_count)
|
||||
|
||||
# Add the server if everything was matched
|
||||
if matched_filters == filter_count:
|
||||
# Create a result with only the fields requested
|
||||
result = {}
|
||||
|
||||
if 'localip0' in server:
|
||||
# localip1, localip2, ... are possible, but are they ever used?
|
||||
# Small chance this might cause an issue later.
|
||||
result['localip0'] = server['localip0']
|
||||
|
||||
if 'localport' in server:
|
||||
result['localport'] = server['localport']
|
||||
|
||||
if 'localport' in server:
|
||||
result['localport'] = server['localport']
|
||||
|
||||
if 'natneg' in server:
|
||||
result['natneg'] = server['natneg']
|
||||
|
||||
if 'publicip' in server:
|
||||
result['publicip'] = server['publicip']
|
||||
|
||||
if 'publicport' in server:
|
||||
result['publicport'] = server['publicport']
|
||||
|
||||
if '__session__' in server:
|
||||
result['__session__'] = server['__session__']
|
||||
|
||||
requested = {}
|
||||
for field in fields:
|
||||
if not field in result:
|
||||
if field in server:
|
||||
requested[field] = server[field]
|
||||
else:
|
||||
# Return a dummy value. What's the normal behavior of the real server in this case?
|
||||
requested[field] = ""
|
||||
|
||||
|
||||
result['requested'] = requested
|
||||
servers.append(result)
|
||||
|
||||
return servers
|
||||
|
||||
def update_server_list(self, gameid, session, value):
|
||||
# Make sure the user isn't hosting multiple servers or there isn't some left over server information that
|
||||
# never got handled properly (game crashed, etc).
|
||||
self.delete_server(gameid, session)
|
||||
|
||||
# If the game doesn't exist already, create a new list.
|
||||
if not gameid in self.server_list:
|
||||
self.server_list[gameid] = []
|
||||
|
||||
# Add new server
|
||||
value['__session__'] = session
|
||||
|
||||
logger.log(logging.DEBUG, "Added %s to the server list for %s" % (value, gameid))
|
||||
self.server_list[gameid].append(value)
|
||||
logger.log(logging.DEBUG, "%s servers: %d" % (gameid, len(self.server_list[gameid])))
|
||||
|
||||
def delete_server(self, gameid, session):
|
||||
if not gameid in self.server_list:
|
||||
# Nothing to do if no servers for that game even exist.
|
||||
return
|
||||
|
||||
# Remove all servers hosted by the given session id.
|
||||
count = len(self.server_list[gameid])
|
||||
self.server_list[gameid] = [x for x in self.server_list[gameid] if x['__session__'] != session]
|
||||
count -= len(self.server_list[gameid])
|
||||
logger.log(logging.DEBUG, "Deleted %d %s servers where session = %d" % (count, gameid, session))
|
||||
|
||||
def find_server_by_address(self, ip, port, gameid = None):
|
||||
if gameid == None:
|
||||
# Search all servers
|
||||
for gameid in self.server_list:
|
||||
for server in self.server_list[gameid]:
|
||||
if server['publicip'] == ip and server['publicport'] == str(port):
|
||||
return server
|
||||
else:
|
||||
for server in self.server_list[gameid]:
|
||||
if server['publicip'] == ip and server['publicport'] == str(port):
|
||||
return server
|
||||
|
||||
return None
|
||||
|
||||
if __name__ == '__main__':
|
||||
freeze_support()
|
||||
|
||||
backend_server = GameSpyBackendServer()
|
||||
backend_server.start()
|
||||
109
gamespy_natneg_server.py
Normal file
109
gamespy_natneg_server.py
Normal file
@@ -0,0 +1,109 @@
|
||||
# Server emulator for *.available.gs.nintendowifi.net and *.master.gs.nintendowifi.net
|
||||
# Query and Reporting: http://docs.poweredbygamespy.com/wiki/Query_and_Reporting_Overview
|
||||
|
||||
import logging
|
||||
import socket
|
||||
import struct
|
||||
import gamespy.gs_utility as gs_utils
|
||||
import other.utils as utils
|
||||
|
||||
logger = utils.create_logger("GameSpyNatNegServer", "gamespy_natneg_server.log", -1)
|
||||
class GameSpyNatNegServer(object):
|
||||
def __init__(self):
|
||||
self.session_list = {}
|
||||
self.secret_key_list = gs_utils.generate_secret_keys("gslist.cfg")
|
||||
|
||||
def start(self):
|
||||
# Start QR server
|
||||
address = ('0.0.0.0', 27901) # accessible to outside connections (use this if you don't know what you're doing)
|
||||
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.bind(address)
|
||||
|
||||
logger.log(logging.DEBUG, "Server is now listening on %s:%s..." % (address[0], address[1]))
|
||||
|
||||
while 1:
|
||||
recv_data, addr = s.recvfrom(2048)
|
||||
|
||||
logger.log(logging.DEBUG, "Connection from %s:%d..." % (addr[0], addr[1]))
|
||||
|
||||
# Make sure it's a legal packet
|
||||
if recv_data[0:6] != bytearray([0xfd, 0xfc, 0x1e, 0x66, 0x6a, 0xb2]):
|
||||
continue
|
||||
|
||||
session_id = struct.unpack("<I", recv_data[8:12])[0]
|
||||
session_id_raw = recv_data[8:12]
|
||||
|
||||
# Handle commands
|
||||
if recv_data[7] == '\x00':
|
||||
logger.log(logging.DEBUG, "Received initialization from %s:%s..." % (addr[0], addr[1]))
|
||||
|
||||
gameid = utils.get_string(recv_data, 0x16)
|
||||
client_id = "%02x" % ord(recv_data[13])
|
||||
|
||||
if gameid not in self.session_list:
|
||||
self.session_list[gameid] = {}
|
||||
if session_id not in self.session_list[gameid]:
|
||||
self.session_list[gameid][session_id] = {}
|
||||
if client_id not in self.session_list[gameid][session_id]:
|
||||
self.session_list[gameid][session_id][client_id] = { 'connected': False, 'addr': '' }
|
||||
|
||||
self.session_list[gameid][session_id][client_id]['addr'] = addr
|
||||
clients = len(self.session_list[gameid][session_id])
|
||||
#if client_id in session_list[gameid][session_id]:
|
||||
# clients -= 1
|
||||
|
||||
if clients > 0:
|
||||
# Someone else is waiting to connect, send message
|
||||
for client in self.session_list[gameid][session_id]:
|
||||
if self.session_list[gameid][session_id][client]['connected'] == True or client == client_id:
|
||||
continue
|
||||
|
||||
output = bytearray(recv_data[0:12])
|
||||
if client == client_id:
|
||||
output += bytearray(recv_data[15:15+4+2]) # IP Address and port
|
||||
else:
|
||||
output += bytearray([int(x) for x in addr[0].split('.')])
|
||||
output += utils.get_bytes_from_short_be(addr[1])
|
||||
|
||||
output += bytearray([0x42, 0x00]) # Unknown, always seems to be \x42\x00
|
||||
output[7] = 0x05
|
||||
s.sendto(output, (self.session_list[gameid][session_id][client]['addr']))
|
||||
|
||||
logger.log(logging.DEBUG, "Sent connection request to %s:%d..." % (self.session_list[gameid][session_id][client]['addr'][0], self.session_list[gameid][session_id][client]['addr'][1]))
|
||||
logger.log(logging.DEBUG, utils.pretty_print_hex(output))
|
||||
logger.log(logging.DEBUG, "")
|
||||
#session_list[gameid][session_id][client_id]['connected'] = True
|
||||
|
||||
output = bytearray(recv_data[0:14])
|
||||
output += bytearray([0xff, 0xff, 0x6d, 0x16, 0xb5, 0x7d, 0xea ]) # Checked with Tetris DS, Mario Kart DS, and Metroid Prime Hunters, and this seems to be the standard response to 0x00
|
||||
output[7] = 0x01 # Initialization response
|
||||
s.sendto(output, addr)
|
||||
|
||||
elif recv_data[7] == '\x06': # Was able to connect
|
||||
client_id = "%02x" % ord(recv_data[13])
|
||||
logger.log(logging.DEBUG, "Received connected command from %s:%s..." % (addr[0], addr[1]))
|
||||
|
||||
if gameid not in self.session_list:
|
||||
pass
|
||||
if session_id not in self.session_list[gameid]:
|
||||
pass
|
||||
if client_id not in self.session_list[gameid][session_id]:
|
||||
pass
|
||||
|
||||
#session_list[gameid][session_id][client_id]['connected'] = True
|
||||
|
||||
elif recv_data[7] == '\x0d':
|
||||
client_id = "%02x" % ord(recv_data[13])
|
||||
logger.log(logging.DEBUG, "Received report command from %s:%s..." % (addr[0], addr[1]))
|
||||
logger.log(logging.DEBUG, utils.pretty_print_hex(recv_data))
|
||||
|
||||
output[7] = 0x0e # Report response
|
||||
s.sendto(output, addr)
|
||||
|
||||
else: # Was able to connect
|
||||
logger.log(logging.DEBUG, "Received unknown command %02x from %s:%s..." % (ord(recv_data[7]), addr[0], addr[1]))
|
||||
|
||||
if __name__ == "__main__":
|
||||
natneg_server = GameSpyNatNegServer()
|
||||
natneg_server.start()
|
||||
96
gamespy_player_search_server.py
Normal file
96
gamespy_player_search_server.py
Normal file
@@ -0,0 +1,96 @@
|
||||
import logging
|
||||
|
||||
from twisted.internet.protocol import Factory
|
||||
from twisted.internet.endpoints import serverFromString
|
||||
from twisted.protocols.basic import LineReceiver
|
||||
from twisted.internet import reactor
|
||||
from twisted.internet.error import ReactorAlreadyRunning
|
||||
|
||||
import gamespy.gs_database as gs_database
|
||||
import gamespy.gs_query as gs_query
|
||||
import other.utils as utils
|
||||
|
||||
logger = utils.create_logger("GameSpyPlayerSearchServer", "gamespy_player_search_server.log", -1)
|
||||
class GameSpyPlayerSearchServer(object):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
endpoint_search = serverFromString(reactor, "tcp:29901")
|
||||
conn_search = endpoint_search.listen(PlayerSearchFactory())
|
||||
|
||||
try:
|
||||
if reactor.running == False:
|
||||
reactor.run(installSignalHandlers=0)
|
||||
except ReactorAlreadyRunning:
|
||||
pass
|
||||
|
||||
|
||||
class PlayerSearchFactory(Factory):
|
||||
def __init__(self):
|
||||
logger.log(logging.INFO, "Now listening for player search connections...")
|
||||
|
||||
def buildProtocol(self, address):
|
||||
return PlayerSearch(address)
|
||||
|
||||
|
||||
class PlayerSearch(LineReceiver):
|
||||
def __init__(self, address):
|
||||
self.setRawMode()
|
||||
self.db = gs_database.GamespyDatabase()
|
||||
|
||||
self,address = address
|
||||
self.leftover = ""
|
||||
|
||||
def connectionMade(self):
|
||||
pass
|
||||
|
||||
def connectionLost(self, reason):
|
||||
pass
|
||||
|
||||
def rawDataReceived(self, data):
|
||||
self.log(logging.DEBUG, "SEARCH RESPONSE: %s" % data)
|
||||
|
||||
data = self.leftover + data
|
||||
commands, self.leftover = gs_query.parse_gamespy_message(data)
|
||||
|
||||
for data_parsed in commands:
|
||||
print data_parsed
|
||||
|
||||
if data_parsed['__cmd__'] == "otherslist":
|
||||
self.perform_otherslist(data_parsed)
|
||||
else:
|
||||
self.log(logging.DEBUG, "Found unknown search command, don't know how to handle '%s'." % data_parsed['__cmd__'])
|
||||
|
||||
def perform_otherslist(self, data_parsed):
|
||||
# Reference: http://wiki.tockdom.com/wiki/MKWii_Network_Protocol/Server/gpsp.gs.nintendowifi.net
|
||||
# Example from: filtered-mkw-log-2014-01-01-ct1310.eth
|
||||
# \otherslist\\o\146376154\uniquenick\2m0isbjmvRMCJ2i5321j\o\192817284\uniquenick\1jhggtmghRMCJ2jrsh23\o\302594991\uniquenick\7dkjp51v5RMCJ2nr3vs9\o\368031897\uniquenick\1v7p3qmkpRMCJ1o8f56p\o\447214276\uniquenick\7dkt0p6gtRMCJ2ljh72h\o\449615791\uniquenick\4puvrm1g4RMCJ00ho3v1\o\460250854\uniquenick\4rik5l1u1RMCJ0tc3fii\o\456284963\uniquenick\1unitvi86RMCJ1b10u02\o\453830866\uniquenick\7de3q52dbRMCJ2877ss2\o\450197498\uniquenick\3qtutr1ikRMCJ38gem1n\o\444241868\uniquenick\67tp53bs9RMCJ1abs7ej\o\420030955\uniquenick\5blesqia3RMCJ322bbd6\o\394609454\uniquenick\0hddp7mq2RMCJ30uv7r7\o\369478991\uniquenick\59de9c2bhRMCJ0re0fii\o\362755626\uniquenick\5tte2lif7RMCJ0cscgtg\o\350951571\uniquenick\7aeummjlaRMCJ3li4ls2\o\350740680\uniquenick\484uiqhr4RMCJ18opoj0\o\349855648\uniquenick\5blesqia3RMCJ1c245dn\o\324078642\uniquenick\62go5gpt0RMCJ0v0uhc9\o\304111337\uniquenick\4lcg6ampvRMCJ1gjre51\o\301273266\uniquenick\1dhdpjhn8RMCJ2da6f9h\o\193178453\uniquenick\3pcgu0299RMCJ3nhu50f\o\187210028\uniquenick\3tau15a9lRMCJ2ar247h\o\461622261\uniquenick\59epddrnkRMCJ1t2ge7l\oldone\\final\
|
||||
|
||||
msg_d = []
|
||||
msg_d.append(('__cmd__', "otherslist"))
|
||||
msg_d.append(('__cmd_val__', ""))
|
||||
|
||||
if "numopids" in data_parsed and "opids" in data_parsed:
|
||||
numopids = int(data_parsed['numopids'])
|
||||
opids = data_parsed['opids'].split('|')
|
||||
|
||||
if len(opids) != numopids:
|
||||
print "Unexpected number of opids, got %d, expected %d." % (len(opids), numopids)
|
||||
|
||||
# Return all uniquenicks despite any unexpected/missing opids
|
||||
for opid in opids:
|
||||
profile = self.db.get_profile_from_profileid(opid)
|
||||
|
||||
if profile != None:
|
||||
msg_d.append(('o', opid))
|
||||
msg_d.append(('uniquenick', profile['uniquenick']))
|
||||
|
||||
msg_d.append(('oldone', ""))
|
||||
msg = gs_query.create_gamespy_message(msg_d)
|
||||
|
||||
self.transport.write(bytes(msg))
|
||||
|
||||
if __name__ == "__main__":
|
||||
gsps = GameSpyPlayerSearchServer()
|
||||
gsps.start()
|
||||
@@ -1,23 +1,67 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from twisted.internet.protocol import Factory
|
||||
from twisted.internet.endpoints import serverFromString
|
||||
from twisted.protocols.basic import LineReceiver
|
||||
from twisted.internet import reactor
|
||||
from twisted.internet.error import ReactorAlreadyRunning
|
||||
|
||||
import gamespy.gs_database as gs_database
|
||||
import gamespy.gs_query as gs_query
|
||||
import gamespy.gs_utility as gs_utils
|
||||
import other.utils as utils
|
||||
|
||||
|
||||
logger = utils.create_logger("GameSpyProfileServer", "gamespy_profile_server.log", -1)
|
||||
class GameSpyProfileServer(object):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
endpoint = serverFromString(reactor, "tcp:29900")
|
||||
conn = endpoint.listen(PlayerFactory())
|
||||
|
||||
try:
|
||||
if reactor.running == False:
|
||||
reactor.run(installSignalHandlers=0)
|
||||
except ReactorAlreadyRunning:
|
||||
pass
|
||||
|
||||
class PlayerFactory(Factory):
|
||||
def __init__(self):
|
||||
# Instead of storing the sessions in the database, it might make more sense to store them in the PlayerFactory.
|
||||
logger.log(logging.INFO, "Now listening for connections...")
|
||||
self.sessions = {}
|
||||
|
||||
def buildProtocol(self, address):
|
||||
return PlayerSession(self.sessions, address)
|
||||
|
||||
class PlayerSession(LineReceiver):
|
||||
def __init__(self, sessions, addr):
|
||||
self.sessions = sessions
|
||||
self.leftover = ""
|
||||
def __init__(self, sessions, address):
|
||||
self.setRawMode() # We're dealing with binary data so set to raw mode
|
||||
|
||||
self.db = gs_database.GamespyDatabase()
|
||||
|
||||
self.sessions = sessions
|
||||
self.address = address
|
||||
self.remaining_message = "" # Stores any unparsable/incomplete commands until the next rawDataReceived
|
||||
|
||||
self.profileId = 0
|
||||
self.address = addr
|
||||
self.gameid = ""
|
||||
|
||||
def log(self, level, message):
|
||||
if self.profileId == 0:
|
||||
if self.gameid == "":
|
||||
logger.log(level, "[%s:%d] %s", self.address.host, self.address.port,message)
|
||||
else:
|
||||
logger.log(level, "[%s:%d | %s] %s", self.address.host, self.address.port, self.gameid, message)
|
||||
else:
|
||||
if self.gameid == "":
|
||||
logger.log(level, "[%s:%d | %d] %s", self.address.host, self.address.port, self.profileId, message)
|
||||
else:
|
||||
logger.log(level, "[%s:%d | %d | %s] %s", self.address.host, self.address.port, self.profileId, self.gameid, message)
|
||||
|
||||
def get_ip_as_int(self, address):
|
||||
ipaddress = 0
|
||||
|
||||
@@ -28,10 +72,15 @@ class PlayerSession(LineReceiver):
|
||||
return ipaddress
|
||||
|
||||
def connectionMade(self):
|
||||
self.log(logging.DEBUG, "Received connection from %s:%d" % (self.address.host, self.address.port))
|
||||
|
||||
# Create new session id
|
||||
self.session = ""
|
||||
|
||||
# Generate a random challenge string
|
||||
self.challenge = utils.generate_random_str(8)
|
||||
|
||||
# The first command sent to the client is always a login challenge containing the server challenge key.
|
||||
msg_d = []
|
||||
msg_d.append(('__cmd__', "lc"))
|
||||
msg_d.append(('__cmd_val__', "1"))
|
||||
@@ -39,21 +88,27 @@ class PlayerSession(LineReceiver):
|
||||
msg_d.append(('id', "1"))
|
||||
msg = gs_query.create_gamespy_message(msg_d)
|
||||
|
||||
utils.print_log("SENDING: '%s'..." % msg)
|
||||
self.log(logging.DEBUG, "SENDING: '%s'..." % msg)
|
||||
self.transport.write(bytes(msg))
|
||||
|
||||
def connectionLost(self, reason):
|
||||
self.log(logging.DEBUG, "Client disconnected")
|
||||
|
||||
if self.session in self.sessions:
|
||||
del self.sessions[self.session]
|
||||
self.log(logging.DEBUG, "Deleted session %d" % self.sessions)
|
||||
|
||||
def rawDataReceived(self, data):
|
||||
utils.print_log("RESPONSE: %s" % data)
|
||||
self.log(logging.DEBUG, "RESPONSE: '%s'..." % data)
|
||||
|
||||
data = self.leftover + data
|
||||
commands, self.leftover = gs_query.parse_gamespy_message(data)
|
||||
# In the case where command string is too big to fit into one read, any parts that could not be successfully
|
||||
# parsed are stored in the variable remaining_message. On the next rawDataReceived command, the remaining
|
||||
# message and the data are combined to create a full command.
|
||||
data = self.remaining_message + data
|
||||
commands, self.remaining_message = gs_query.parse_gamespy_message(data)
|
||||
|
||||
for data_parsed in commands:
|
||||
print data_parsed
|
||||
self.log(-1, data_parsed)
|
||||
|
||||
if data_parsed['__cmd__'] == "login":
|
||||
self.perform_login(data_parsed)
|
||||
@@ -77,7 +132,7 @@ class PlayerSession(LineReceiver):
|
||||
self.perform_authadd(data_parsed)
|
||||
else:
|
||||
# Maybe write unknown commands to a separate file later so new data can be collected more easily?
|
||||
utils.print_log("Found unknown command, don't know how to handle '%s'." % data_parsed['__cmd__'])
|
||||
self.log(logging.DEBUG, "Found unknown command, don't know how to handle '%s'." % data_parsed['__cmd__'])
|
||||
|
||||
def perform_login(self, data_parsed):
|
||||
authtoken_parsed = gs_utils.parse_authtoken(data_parsed['authtoken'])
|
||||
@@ -136,7 +191,7 @@ class PlayerSession(LineReceiver):
|
||||
# Verify the client's response
|
||||
valid_response = gs_utils.generate_response(self.challenge, authtoken_parsed['challenge'], data_parsed['challenge'], data_parsed['authtoken'])
|
||||
if data_parsed['response'] != valid_response:
|
||||
utils.print_log("ERROR: Got invalid response. Got %s, expected %s" % (data_parsed['response'], valid_response))
|
||||
self.log(logging.DEBUG, "ERROR: Got invalid response. Got %s, expected %s" % (data_parsed['response'], valid_response))
|
||||
|
||||
proof = gs_utils.generate_proof(self.challenge, authtoken_parsed['challenge'], data_parsed['challenge'], data_parsed['authtoken'])
|
||||
|
||||
@@ -178,7 +233,7 @@ class PlayerSession(LineReceiver):
|
||||
self.gameid = gsbrcd[0:4]
|
||||
self.profileid = profileid
|
||||
|
||||
utils.print_log("SENDING: %s" % msg)
|
||||
self.log(logging.DEBUG, "SENDING: %s" % msg)
|
||||
self.transport.write(bytes(msg))
|
||||
|
||||
# Send any friend statuses when the user logs in.
|
||||
@@ -210,14 +265,14 @@ class PlayerSession(LineReceiver):
|
||||
|
||||
if profile['lastname'] != "":
|
||||
msg_d.append(('lastname', profile['lastname']))
|
||||
|
||||
|
||||
msg_d.append(('lon', profile['lon']))
|
||||
msg_d.append(('lat', profile['lat']))
|
||||
msg_d.append(('loc', profile['loc']))
|
||||
msg_d.append(('id', data_parsed['id']))
|
||||
msg = gs_query.create_gamespy_message(msg_d)
|
||||
|
||||
utils.print_log("SENDING: %s" % msg)
|
||||
self.log(logging.DEBUG, "SENDING: %s" % msg)
|
||||
self.transport.write(bytes(msg))
|
||||
|
||||
|
||||
@@ -239,11 +294,9 @@ class PlayerSession(LineReceiver):
|
||||
data_parsed.pop('sesskey')
|
||||
|
||||
# Create a list of fields to be updated.
|
||||
fields = []
|
||||
for f in data_parsed:
|
||||
fields.append((f, data_parsed[f]))
|
||||
self.db.update_profile(sesskey, (f, data_parsed[f]))
|
||||
|
||||
self.db.update_profile(sesskey, fields)
|
||||
|
||||
def perform_ka(self, data_parsed):
|
||||
# No op
|
||||
@@ -284,7 +337,7 @@ class PlayerSession(LineReceiver):
|
||||
msg_d.append(('msg', dest_msg))
|
||||
msg = gs_query.create_gamespy_message(msg_d)
|
||||
|
||||
utils.print_log("SENDING TO %s:%s: %s" % (self.sessions[dest_profileid].address.host, self.sessions[dest_profileid].address.port, msg))
|
||||
self.log(logging.DEBUG, "SENDING TO %s:%s: %s" % (self.sessions[dest_profileid].address.host, self.sessions[dest_profileid].address.port, msg))
|
||||
self.sessions[dest_profileid].transport.write(bytes(msg))
|
||||
|
||||
|
||||
@@ -394,87 +447,8 @@ class PlayerSession(LineReceiver):
|
||||
self.transport.write(bytes(msg))
|
||||
|
||||
|
||||
class PlayerSearch(LineReceiver):
|
||||
def __init__(self, sessions, addr):
|
||||
self.sessions = sessions
|
||||
self.setRawMode()
|
||||
self.db = gs_database.GamespyDatabase()
|
||||
self.leftover = ""
|
||||
|
||||
def connectionMade(self):
|
||||
pass
|
||||
|
||||
def connectionLost(self, reason):
|
||||
pass
|
||||
|
||||
def rawDataReceived(self, data):
|
||||
utils.print_log("SEARCH RESPONSE: %s" % data)
|
||||
|
||||
data = self.leftover + data
|
||||
commands, self.leftover = gs_query.parse_gamespy_message(data)
|
||||
|
||||
for data_parsed in commands:
|
||||
print data_parsed
|
||||
|
||||
if data_parsed['__cmd__'] == "otherslist":
|
||||
self.perform_otherslist(data_parsed)
|
||||
else:
|
||||
utils.print_log("Found unknown search command, don't know how to handle '%s'." % data_parsed['__cmd__'])
|
||||
|
||||
def perform_otherslist(self, data_parsed):
|
||||
# Reference: http://wiki.tockdom.com/wiki/MKWii_Network_Protocol/Server/gpsp.gs.nintendowifi.net
|
||||
# Example from: filtered-mkw-log-2014-01-01-ct1310.eth
|
||||
# \otherslist\\o\146376154\uniquenick\2m0isbjmvRMCJ2i5321j\o\192817284\uniquenick\1jhggtmghRMCJ2jrsh23\o\302594991\uniquenick\7dkjp51v5RMCJ2nr3vs9\o\368031897\uniquenick\1v7p3qmkpRMCJ1o8f56p\o\447214276\uniquenick\7dkt0p6gtRMCJ2ljh72h\o\449615791\uniquenick\4puvrm1g4RMCJ00ho3v1\o\460250854\uniquenick\4rik5l1u1RMCJ0tc3fii\o\456284963\uniquenick\1unitvi86RMCJ1b10u02\o\453830866\uniquenick\7de3q52dbRMCJ2877ss2\o\450197498\uniquenick\3qtutr1ikRMCJ38gem1n\o\444241868\uniquenick\67tp53bs9RMCJ1abs7ej\o\420030955\uniquenick\5blesqia3RMCJ322bbd6\o\394609454\uniquenick\0hddp7mq2RMCJ30uv7r7\o\369478991\uniquenick\59de9c2bhRMCJ0re0fii\o\362755626\uniquenick\5tte2lif7RMCJ0cscgtg\o\350951571\uniquenick\7aeummjlaRMCJ3li4ls2\o\350740680\uniquenick\484uiqhr4RMCJ18opoj0\o\349855648\uniquenick\5blesqia3RMCJ1c245dn\o\324078642\uniquenick\62go5gpt0RMCJ0v0uhc9\o\304111337\uniquenick\4lcg6ampvRMCJ1gjre51\o\301273266\uniquenick\1dhdpjhn8RMCJ2da6f9h\o\193178453\uniquenick\3pcgu0299RMCJ3nhu50f\o\187210028\uniquenick\3tau15a9lRMCJ2ar247h\o\461622261\uniquenick\59epddrnkRMCJ1t2ge7l\oldone\\final\
|
||||
|
||||
msg_d = []
|
||||
msg_d.append(('__cmd__', "otherslist"))
|
||||
msg_d.append(('__cmd_val__', ""))
|
||||
|
||||
if "numopids" in data_parsed and "opids" in data_parsed:
|
||||
numopids = int(data_parsed['numopids'])
|
||||
opids = data_parsed['opids'].split('|')
|
||||
|
||||
if len(opids) != numopids:
|
||||
print "Unexpected number of opids, got %d, expected %d." % (len(opids), numopids)
|
||||
|
||||
# Return all uniquenicks despite any unexpected/missing opids
|
||||
for opid in opids:
|
||||
profile = self.db.get_profile_from_profileid(opid)
|
||||
|
||||
if profile != None:
|
||||
msg_d.append(('o', opid))
|
||||
msg_d.append(('uniquenick', profile['uniquenick']))
|
||||
|
||||
msg_d.append(('oldone', ""))
|
||||
msg = gs_query.create_gamespy_message(msg_d)
|
||||
|
||||
self.transport.write(bytes(msg))
|
||||
|
||||
|
||||
sessions = {}
|
||||
class PlayerFactory(Factory):
|
||||
def __init__(self):
|
||||
# Instead of storing the sessions in the database, it might make more sense to store them in the PlayerFactory.
|
||||
print "Now listening for connections..."
|
||||
|
||||
def buildProtocol(self, addr):
|
||||
return PlayerSession(sessions, addr)
|
||||
|
||||
|
||||
class PlayerSearchFactory(Factory):
|
||||
def __init__(self):
|
||||
self.sessions = {}
|
||||
print "Now listening for player search connections..."
|
||||
|
||||
def buildProtocol(self, addr):
|
||||
return PlayerSearch(sessions, addr)
|
||||
|
||||
|
||||
endpoint = serverFromString(reactor, "tcp:29900")
|
||||
conn = endpoint.listen(PlayerFactory())
|
||||
|
||||
endpoint_search = serverFromString(reactor, "tcp:29901")
|
||||
conn_search = endpoint.listen(PlayerSearchFactory())
|
||||
|
||||
reactor.run()
|
||||
|
||||
if __name__ == "__main__":
|
||||
gsps = GameSpyProfileServer()
|
||||
gsps.start()
|
||||
248
gamespy_qr_server.py
Normal file
248
gamespy_qr_server.py
Normal file
@@ -0,0 +1,248 @@
|
||||
# Server emulator for *.available.gs.nintendowifi.net and *.master.gs.nintendowifi.net
|
||||
# Query and Reporting: http://docs.poweredbygamespy.com/wiki/Query_and_Reporting_Overview
|
||||
|
||||
import logging
|
||||
import socket
|
||||
import struct
|
||||
import gamespy.gs_utility as gs_utils
|
||||
import other.utils as utils
|
||||
|
||||
from multiprocessing.managers import BaseManager
|
||||
|
||||
logger = utils.create_logger("GameSpyQRServer", "gamespy_qr_server.log", -1)
|
||||
|
||||
class GameSpyServerDatabase(BaseManager):
|
||||
pass
|
||||
|
||||
class GameSpyQRServer(object):
|
||||
class Session(object):
|
||||
def __init__(self, address):
|
||||
self.session = ""
|
||||
self.challenge = ""
|
||||
self.secretkey = "" # Parse gslist.cfg later
|
||||
self.sent_challenge = False
|
||||
self.address = address
|
||||
|
||||
def __init__(self):
|
||||
self.sessions = {}
|
||||
|
||||
# Generate a dictionary "secret_key_list" containing the secret game keys associated with their game IDs.
|
||||
# The dictionary key will be the game's ID, and the value will be the secret key.
|
||||
self.secret_key_list = gs_utils.generate_secret_keys("gslist.cfg")
|
||||
#self.log(logging.DEBUG, address, "Generated list of secret game keys...")
|
||||
|
||||
GameSpyServerDatabase.register("update_server_list")
|
||||
GameSpyServerDatabase.register("delete_server")
|
||||
|
||||
def log(self, level, address, message):
|
||||
logger.log(level, "[%s:%d] %s", address[0], address[1],message)
|
||||
|
||||
def start(self):
|
||||
manager_address = ("127.0.0.1", 27500)
|
||||
manager_password = ""
|
||||
|
||||
self.server_manager = GameSpyServerDatabase(address = manager_address, authkey= manager_password)
|
||||
self.server_manager.connect()
|
||||
|
||||
# Start QR server
|
||||
address = ('0.0.0.0', 27900) # accessible to outside connections (use this if you don't know what you're doing)
|
||||
|
||||
self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self.socket.bind(address)
|
||||
|
||||
logger.log(logging.DEBUG, "Server is now listening on %s:%s..." % (address[0], address[1]))
|
||||
|
||||
self.wait_loop()
|
||||
|
||||
def wait_loop(self):
|
||||
while 1:
|
||||
recv_data, address = self.socket.recvfrom(2048)
|
||||
|
||||
# Tetris DS overlay 10 @ 02144184 - Handle responses back to server
|
||||
# Tetris DS overlay 10 @ 02144184 - Handle responses back to server
|
||||
#
|
||||
# After some more packet inspection, it seems the format goes something like this:
|
||||
# - All server messages seem to always start with \xfe\xfd.
|
||||
# - The first byte from the client (or third byte from the server) is a command.
|
||||
# - Bytes 2 - 5 from the client is some kind of ID. This will have to be inspected later. I believe it's a
|
||||
# session-like ID because the number changes between connections. Copying the client's ID might be enough.
|
||||
#
|
||||
# The above was as guessed.
|
||||
# The code in Tetris DS (overlay 10) @ 0216E974 handles the network command creation.
|
||||
# R1 contains the command to be sent to the server.
|
||||
# R2 contains a pointer to some unknown integer that gets written after the command.
|
||||
#
|
||||
# - Commands
|
||||
# Commands range from 0x00 to 0x09 (for client only at least?) (Tetris DS overlay 10 @ 0216DDCC)
|
||||
#
|
||||
# CLIENT:
|
||||
# 0x01 - Response (Tetris DS overlay 10 @ 216DCA4)
|
||||
# Sends back base64 of RC4 encrypted string that was gotten from the server's 0x01.
|
||||
#
|
||||
# 0x03 - Send client state? (Tetris DS overlay 10 @ 216DA30)
|
||||
# Data sent:
|
||||
# 1) Loop for each localip available on the system, write as localip%d\x00(local ip)
|
||||
# 2) localport\x00(local port)
|
||||
# 3) natneg (either 0 or 1)
|
||||
# 4) ONLY IF STATE CHANGED: statechanged\x00(state) (Possible values: 0, 1, 2, 3)
|
||||
# 5) gamename\x00(game name)
|
||||
# 6) ONLY IF PUBLIC IP AND PORT ARE AVAILABLE: publicip\x00(public ip)
|
||||
# 7) ONLY IF PUBLIC IP AND PORT ARE AVAILABLE: publicport\x00(public port)
|
||||
#
|
||||
# if statechanged != 2:
|
||||
# Write various other data described here: http://docs.poweredbygamespy.com/wiki/Query_and_Reporting_Implementation
|
||||
#
|
||||
# 0x07 - Unknown, related to server's 0x06 (returns value sent from server)
|
||||
#
|
||||
# 0x08 - Keep alive? Sent after 0x03
|
||||
#
|
||||
# 0x09 - Availability check
|
||||
#
|
||||
# SERVER:
|
||||
# 0x01 - Unknown
|
||||
# Data sent:
|
||||
# 8 random ASCII characters (?) followed by the public IP and port of the client as a hex string
|
||||
#
|
||||
# 0x06 - Unknown
|
||||
# First 4 bytes is some kind of id? I believe it's a unique identifier for the data being sent,
|
||||
# seeing how the server can send the same IP information many times in a row. If the IP information has
|
||||
# already been parsed then it doesn't waste time handling it.
|
||||
#
|
||||
# After that is a "SBCM" section which is 0x14 bytes in total.
|
||||
# SBCM information gets parsed at 2141A0C in Tetris DS overlay 10.
|
||||
# Seems to contain IP address information.
|
||||
#
|
||||
# The SBCM seems to contain a little information that must be parsed before.
|
||||
# After the SBCM:
|
||||
# \x03\x00\x00\x00 - Always the same?
|
||||
# \x01 - Found player?
|
||||
# \x04 - Unknown
|
||||
# (2 bytes) - Unknown. Port?
|
||||
# (4 bytes) - Player's IP
|
||||
# (4 bytes) - Unknown. Some other IP? Remote server IP?
|
||||
# \x00\x00\x00\x00 - Unknown but seems to get checked
|
||||
#
|
||||
# Another SBCM, after a player has been found and attempting to start a game:
|
||||
# \x03\x00\x00\x00 - Always the same?
|
||||
# \x05 - Connecting to player?
|
||||
# \x00 - Unknown
|
||||
# (2 bytes) - Unknown. Port? Same as before.
|
||||
# (4 bytes) - Player's IP
|
||||
# (4 bytes) - Unknown. Some other IP? Remote server IP?
|
||||
#
|
||||
# 0x0a - Response to 0x01
|
||||
# Gets sent after receiving 0x01 from the client. So, server 0x01 -> client 0x01 -> server 0x0a.
|
||||
# Has no other data besides the client ID.
|
||||
#
|
||||
# - \xfd\xfc commands get passed directly between the other player(s)?
|
||||
#
|
||||
#
|
||||
# Open source version of GameSpy found here: https://github.com/sfcspanky/Openspy-Core/tree/master/qr
|
||||
# Use as reference.
|
||||
|
||||
session_id = struct.unpack("<I", recv_data[1:5])[0]
|
||||
session_id_raw = recv_data[1:5]
|
||||
if session_id not in self.sessions:
|
||||
# Found a new session, add to session list
|
||||
self.sessions[session_id] = self.Session(address)
|
||||
|
||||
# Handle commands
|
||||
if recv_data[0] == '\x00': # Query
|
||||
self.log(logging.DEBUG, address, "NOT IMPLEMENTED! Received query from %s:%s... %s" % (address[0], address[1], recv_data[5:]))
|
||||
|
||||
elif recv_data[0] == '\x01': # Challenge
|
||||
self.log(logging.DEBUG, address, "Received challenge from %s:%s... %s" % (address[0], address[1], recv_data[5:]))
|
||||
|
||||
# Prepare the challenge sent from the server to be compared
|
||||
challenge = gs_utils.prepare_rc4_base64(self.sessions[session_id].secretkey, self.sessions[session_id].challenge)
|
||||
|
||||
# Compare challenge
|
||||
client_challenge = recv_data[5:-1]
|
||||
if client_challenge == challenge:
|
||||
# Challenge succeeded
|
||||
self.sessions[session_id].sent_challenge = True
|
||||
|
||||
# Handle successful challenge stuff here
|
||||
packet = bytearray([0xfe, 0xfd, 0x0a]) # Send client registered command
|
||||
packet.extend(session_id_raw) # Get the session ID
|
||||
self.socket.sendto(packet, address)
|
||||
self.log(logging.DEBUG, address, "Sent client registered to %s:%s..." % (address[0], address[1]))
|
||||
|
||||
elif recv_data[0] == '\x02': # Echo
|
||||
self.log(logging.DEBUG, address, "NOT IMPLEMENTED! Received echo from %s:%s... %s" % (address[0], address[1], recv_data[5:]))
|
||||
|
||||
elif recv_data[0] == '\x03': # Heartbeat
|
||||
data = recv_data[5:]
|
||||
self.log(logging.DEBUG, address, "Received heartbeat from %s:%s... %s" % (address[0], address[1], data))
|
||||
|
||||
# Parse information from heartbeat here
|
||||
d = data.rstrip('\0').split('\0')
|
||||
|
||||
# It may be safe to ignore "unknown" keys because the proper key names get filled in later...
|
||||
k = {}
|
||||
for i in range(0, len(d), 2):
|
||||
self.log(logging.DEBUG, address, "%s = %s" % (d[i], d[i+1]))
|
||||
k[d[i]] = d[i+1]
|
||||
|
||||
if "gamename" in k:
|
||||
self.sessions[session_id].secretkey = self.secret_key_list[k['gamename']]
|
||||
#print "Got secret key %s for %s" % (self.sessions[session_id].secretkey, k['gamename'])
|
||||
|
||||
if self.sessions[session_id].sent_challenge == False:
|
||||
addr_hex = ''.join(["%02X" % int(x) for x in address[0].split('.')])
|
||||
port_hex = "%04X" % int(address[1])
|
||||
server_challenge = utils.generate_random_str(8) + addr_hex + port_hex
|
||||
|
||||
self.sessions[session_id].challenge = server_challenge
|
||||
|
||||
packet = bytearray([0xfe, 0xfd, 0x01]) # Send challenge command
|
||||
packet.extend(session_id_raw) # Get the session ID
|
||||
packet.extend(server_challenge)
|
||||
packet.extend('\x00')
|
||||
|
||||
self.socket.sendto(packet, address)
|
||||
self.log(logging.DEBUG, address, "Sent challenge to %s:%s..." % (address[0], address[1]))
|
||||
|
||||
if "statechanged" in k:
|
||||
if k['statechanged'] == "1": # Create server
|
||||
if k['publicport'] != "0" and k['publicip'] != "0" and k['maxplayers'] != "0":
|
||||
# dwc_mtype controls what kind of server query we're looking for.
|
||||
# dwc_mtype = 0 is used when looking for a matchmaking game.
|
||||
# dwc_mtype = 1 is unknown.
|
||||
# dwc_mtype = 2 is used when hosting a friends only game (possibly other uses too).
|
||||
# dwc_mtype = 3 is used when looking for a friends only game (possibly other uses too).
|
||||
|
||||
# Some memory could be saved by clearing out any unwanted fields from k before sending.
|
||||
self.server_manager.update_server_list(k['gamename'] , session_id, k)
|
||||
elif k['statechanged'] == "2": # Close server
|
||||
self.server_manager.delete_server(k['gamename'] , session_id)
|
||||
#self.sessions.pop(session_id)
|
||||
|
||||
|
||||
elif recv_data[0] == '\x04': # Add Error
|
||||
self.log(logging.DEBUG, address, "NOT IMPLEMENTED! Received add error from %s:%s... %s" % (address[0], address[1], recv_data[5:]))
|
||||
|
||||
elif recv_data[0] == '\x05': # Echo Response
|
||||
self.log(logging.DEBUG, address, "NOT IMPLEMENTED! Received echo response from %s:%s... %s" % (address[0], address[1], recv_data[5:]))
|
||||
|
||||
elif recv_data[0] == '\x06': # Client Message
|
||||
self.log(logging.DEBUG, address, "NOT IMPLEMENTED! Received echo from %s:%s... %s" % (address[0], address[1], recv_data[5:]))
|
||||
|
||||
elif recv_data[0] == '\x07': # Client Message Ack
|
||||
self.log(logging.DEBUG, address, "NOT IMPLEMENTED! Received client message ack from %s:%s... %s" % (address[0], address[1], recv_data[5:]))
|
||||
|
||||
elif recv_data[0] == '\x08': # Keep Alive
|
||||
self.log(logging.DEBUG, address, "Received keep alive from %s:%s..." % (address[0], address[1]))
|
||||
|
||||
elif recv_data[0] == '\x09': # Available
|
||||
# Availability check only sent to *.available.gs.nintendowifi.net
|
||||
self.log(logging.DEBUG, address, "Received availability request for '%s' from %s:%s..." % (recv_data[5: -1], address[0], address[1]))
|
||||
self.socket.sendto(bytearray([0xfe, 0xfd, 0x09, 0x00, 0x00, 0x00, 0x00]), address)
|
||||
|
||||
elif recv_data[0] == '\x0a': # Client Registered
|
||||
# Only sent to client, never received?
|
||||
self.log(logging.DEBUG, address, "NOT IMPLEMENTED! Received client registered from %s:%s... %s" % (address[0], address[1], recv_data[5:]))
|
||||
|
||||
else:
|
||||
self.log(logging.DEBUG, address, "Unknown request from %s:%s:" % (address[0], address[1]))
|
||||
self.log(logging.DEBUG, address, utils.pretty_print_hex(recv_data))
|
||||
310
gamespy_server_browser_server.py
Normal file
310
gamespy_server_browser_server.py
Normal file
@@ -0,0 +1,310 @@
|
||||
# I found an open source implemention of this exact server I'm trying to emulate here: https://github.com/sfcspanky/Openspy-Core/blob/master/serverbrowsing/
|
||||
# Use as reference later.
|
||||
|
||||
import logging
|
||||
import socket
|
||||
import ctypes
|
||||
|
||||
from twisted.internet.protocol import Factory
|
||||
from twisted.internet.endpoints import serverFromString
|
||||
from twisted.protocols.basic import LineReceiver
|
||||
from twisted.internet import reactor
|
||||
from twisted.internet.error import ReactorAlreadyRunning
|
||||
|
||||
import gamespy.gs_utility as gs_utils
|
||||
import other.utils as utils
|
||||
|
||||
from multiprocessing.managers import BaseManager
|
||||
|
||||
class ServerListFlags:
|
||||
UNSOLICITED_UDP_FLAG = 1
|
||||
PRIVATE_IP_FLAG = 2
|
||||
CONNECT_NEGOTIATE_FLAG = 4
|
||||
ICMP_IP_FLAG = 8
|
||||
NONSTANDARD_PORT_FLAG = 16
|
||||
NONSTANDARD_PRIVATE_PORT_FLAG = 32
|
||||
HAS_KEYS_FLAG = 64
|
||||
HAS_FULL_RULES_FLAG = 128
|
||||
|
||||
logger = utils.create_logger("GameSpyServerBrowserServer", "gamespy_server_browser_server.log", -1)
|
||||
|
||||
class GameSpyServerDatabase(BaseManager):
|
||||
pass
|
||||
|
||||
GameSpyServerDatabase.register("get_server_list")
|
||||
GameSpyServerDatabase.register("modify_server_list")
|
||||
GameSpyServerDatabase.register("find_servers")
|
||||
GameSpyServerDatabase.register("find_server_by_address")
|
||||
|
||||
class GameSpyServerBrowserServer(object):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
endpoint = serverFromString(reactor, "tcp:28910")
|
||||
conn = endpoint.listen(SessionFactory())
|
||||
|
||||
try:
|
||||
if reactor.running == False:
|
||||
reactor.run(installSignalHandlers=0)
|
||||
except ReactorAlreadyRunning:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
class SessionFactory(Factory):
|
||||
def __init__(self):
|
||||
logger.log(logging.DEBUG, "Now listening for connections...")
|
||||
self.secret_key_list = gs_utils.generate_secret_keys("gslist.cfg")
|
||||
|
||||
def buildProtocol(self, address):
|
||||
return Session(address, self.secret_key_list)
|
||||
|
||||
|
||||
class Session(LineReceiver):
|
||||
def __init__(self, address, secret_key_list):
|
||||
self.setRawMode() # We're dealing with binary data so set to raw mode
|
||||
self.address = address
|
||||
self.forward_to_client = False
|
||||
self.forward_client = ()
|
||||
self.secret_key_list = secret_key_list # Don't waste time parsing every session, so just accept it from the parent
|
||||
|
||||
manager_address = ("127.0.0.1", 27500)
|
||||
manager_password = ""
|
||||
self.server_manager = GameSpyServerDatabase(address = manager_address, authkey= manager_password)
|
||||
self.server_manager.connect()
|
||||
|
||||
|
||||
def rawDataReceived(self, data):
|
||||
# First 2 bytes are the packet size.
|
||||
#
|
||||
# Third byte is the command byte.
|
||||
# According to Openspy-Core:
|
||||
# 0x00 - Server list request
|
||||
# 0x01 - Server info request
|
||||
# 0x02 - Send message request
|
||||
# 0x03 - Keep alive reply
|
||||
# 0x04 - Map loop request (?)
|
||||
# 0x05 - Player search request
|
||||
#
|
||||
# For Tetris DS, at the very least 0x00 and 0x02 need to be implemented.
|
||||
|
||||
if self.forward_to_client:
|
||||
self.forward_to_client = False
|
||||
|
||||
# Find session id of server
|
||||
# Iterate through the list of servers sent to the client and match by IP and port.
|
||||
# Is there a better way to determine this information?
|
||||
ip = str(ctypes.c_int32(utils.get_int(bytearray([int(x) for x in self.forward_client[0].split('.')]), 0)).value)
|
||||
logger.log(logging.DEBUG, "Trying to send message to %s:%d..." % (self.forward_client[0], self.forward_client[1]))
|
||||
logger.log(logging.DEBUG, utils.pretty_print_hex(bytearray(data)))
|
||||
|
||||
# Get server based on ip/port
|
||||
server = self.server_manager.find_server_by_address(ip, self.forward_client[1])._getvalue()
|
||||
logger.log(logging.DEBUG, server)
|
||||
|
||||
if server == None:
|
||||
pass
|
||||
|
||||
#print "%s %s" % (ip, server['publicip'])
|
||||
if server['publicip'] == ip and server['publicport'] == str(self.forward_client[1]):
|
||||
# Send command to server to get it to connect to natneg
|
||||
natneg_session = int(utils.generate_random_hex_str(8), 16) # Quick and lazy way to get a random 32bit integer. Replace with something else late.r
|
||||
|
||||
output = bytearray([0xfe, 0xfd, 0x06])
|
||||
output += utils.get_bytes_from_int(server['__session__'])
|
||||
output += bytearray(utils.get_bytes_from_int(natneg_session))
|
||||
output += bytearray(data)
|
||||
|
||||
client_s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
client_s.sendto(output, self.forward_client)
|
||||
logger.log(logging.DEBUG, "Forwarded data to %s:%s..." % (self.forward_client[0], self.forward_client[1]))
|
||||
return
|
||||
|
||||
if data[2] == '\x00': # Server list request
|
||||
logger.log(logging.DEBUG, "Received server list request from %s:%s..." % (self.address.host, self.address.port))
|
||||
|
||||
# This code is so... not python. The C programmer in me is coming out strong.
|
||||
# TODO: Rewrite this section later?
|
||||
idx = 3
|
||||
list_version = ord(data[idx])
|
||||
idx += 1
|
||||
encoding_version = ord(data[idx])
|
||||
idx += 1
|
||||
game_version = utils.get_int(data, idx)
|
||||
idx += 4
|
||||
|
||||
query_game = utils.get_string(data, idx)
|
||||
idx += len(query_game) + 1
|
||||
game_name = utils.get_string(data, idx)
|
||||
idx += len(game_name) + 1
|
||||
|
||||
challenge = data[idx:idx+8]
|
||||
idx += 8
|
||||
|
||||
filter = utils.get_string(data, idx)
|
||||
idx += len(filter) + 1
|
||||
fields = utils.get_string(data, idx)
|
||||
idx += len(fields) + 1
|
||||
|
||||
options = utils.get_int_be(data, idx)
|
||||
idx += 4
|
||||
|
||||
source_ip = 0
|
||||
max_servers = 0
|
||||
|
||||
ALTERNATE_SOURCE_IP = 0x08
|
||||
LIMIT_RESULT_COUNT = 0x80
|
||||
if (options & LIMIT_RESULT_COUNT):
|
||||
max_servers = utils.get_int(data, idx)
|
||||
elif (options & ALTERNATE_SOURCE_IP):
|
||||
source_ip = utils.get_int(data, idx)
|
||||
|
||||
if '\\' in fields:
|
||||
fields = [x for x in fields.split('\\') if x and not x.isspace()]
|
||||
|
||||
#print "%02x %02x %08x" % (list_version, encoding_version, game_version)
|
||||
#print "%s" % query_game
|
||||
#print "%s" % game_name
|
||||
#print "%s" % challenge
|
||||
#print "%s" % filter
|
||||
#print "%s" % fields
|
||||
|
||||
#print "%08x" % options
|
||||
#print "%d %08x" % (max_servers, source_ip)
|
||||
|
||||
logger.log(logging.DEBUG, "list version: %02x / encoding version: %02x / game version: %08x / query game: %s / game name: %s / challenge: %s / filter: %s / fields: %s / options: %08x / max servers: %d / source ip: %08x" % (list_version, encoding_version, game_version, query_game, game_name, challenge, filter, fields, options, max_servers, source_ip))
|
||||
|
||||
# Requesting ip and port of client, not server
|
||||
if filter == "" or fields == "":
|
||||
output = bytearray([int(x) for x in self.address.host.split('.')])
|
||||
output += utils.get_bytes_from_short_be(self.address.port)
|
||||
self.transport.write(bytes(output))
|
||||
logger.log(logging.DEBUG, "Responding with own IP and port...")
|
||||
logger.log(logging.DEBUG, utils.pretty_print_hex(output))
|
||||
else:
|
||||
self.find_server(query_game, filter, fields, max_servers, game_name, challenge)
|
||||
|
||||
|
||||
|
||||
elif data[2] == '\x02': # Send message request
|
||||
dest_addr = '.'.join(["%d" % ord(x) for x in data[3:7]])
|
||||
dest_port = utils.get_short_be(data, 7) # What's the pythonic way to do this? unpack?
|
||||
dest = (dest_addr, dest_port)
|
||||
|
||||
logger.log(logging.DEBUG, "Received send message request from %s:%s to %s:%d..." % (self.address.host, self.address.port, dest_addr, dest_port))
|
||||
logger.log(logging.DEBUG, utils.pretty_print_hex(bytearray(data)))
|
||||
|
||||
self.forward_to_client = True
|
||||
self.forward_client = dest
|
||||
|
||||
elif data[2] == '\x03': # Keep alive reply
|
||||
logger.log(logging.DEBUG, "Received keep alive from %s:%s..." % (self.address.host, self.address.port))
|
||||
|
||||
else:
|
||||
logger.log(logging.DEBUG, "Received unknown command (%02x) from %s:%s..." % (ord(data[2]), self.address.host, self.address.port))
|
||||
logger.log(logging.DEBUG, utils.pretty_print_hex(bytearray(data)))
|
||||
logger.log(logging.DEBUG, utils.pretty_print_hex(data))
|
||||
|
||||
def get_game_id(self, data):
|
||||
game_id = data[5: -1]
|
||||
return game_id
|
||||
|
||||
def get_server_list(self, game, filter, fields, max_count):
|
||||
results = self.server_manager.find_servers(game, filter, fields, max_count)
|
||||
return results
|
||||
|
||||
def generate_server_list_data(self, address, fields, server_info):
|
||||
output = bytearray()
|
||||
|
||||
# Write the address
|
||||
output += bytearray([int(x) for x in address.host.split('.')])
|
||||
|
||||
# Write the port
|
||||
output += utils.get_bytes_from_short_be(address.port)
|
||||
|
||||
#if len(server_info) > 0:
|
||||
if True:
|
||||
# Write number of fields that will be returned.
|
||||
key_count = len(fields)
|
||||
output += utils.get_bytes_from_short(key_count)
|
||||
|
||||
if key_count != len(fields):
|
||||
# For some reason we didn't get all of the expected data.
|
||||
logger.log(logging.WARNING, "key_count[%d] != len(fields)[%d]" % (key_count, len(fields)))
|
||||
logger.log(logging.WARNING, fields)
|
||||
|
||||
flags_buffer = bytearray()
|
||||
|
||||
# Write the fields
|
||||
for field in fields:
|
||||
output += bytearray(field) + '\0\0'
|
||||
|
||||
# Start server loop here instead of including all of the fields and stuff again
|
||||
flags = 0
|
||||
if len(server_info) != 0:
|
||||
flags |= ServerListFlags.HAS_KEYS_FLAG
|
||||
|
||||
if "natneg" in server_info:
|
||||
flags |= ServerListFlags.CONNECT_NEGOTIATE_FLAG
|
||||
|
||||
flags_buffer += utils.get_bytes_from_int(int(server_info['publicip']))
|
||||
|
||||
flags |= ServerListFlags.NONSTANDARD_PORT_FLAG
|
||||
flags_buffer += utils.get_bytes_from_short_be(int(server_info['publicport']))
|
||||
|
||||
if "localip0" in server_info:
|
||||
flags |= ServerListFlags.PRIVATE_IP_FLAG
|
||||
flags_buffer += bytearray([int(x) for x in server_info['localip0'].split('.')])
|
||||
|
||||
if "localport" in server_info:
|
||||
flags |= ServerListFlags.NONSTANDARD_PRIVATE_PORT_FLAG
|
||||
flags_buffer += utils.get_bytes_from_short_be(int(server_info['localport']))
|
||||
|
||||
flags |= ServerListFlags.ICMP_IP_FLAG
|
||||
flags_buffer += bytearray([int(x) for x in "0.0.0.0".split('.')])
|
||||
|
||||
output += bytearray([flags & 0xff])
|
||||
output += flags_buffer
|
||||
|
||||
if (flags & ServerListFlags.HAS_KEYS_FLAG):
|
||||
# Write data for associated fields
|
||||
for field in fields:
|
||||
output += '\xff' + bytearray(server_info['requested'][field]) + '\0'
|
||||
|
||||
output += '\0'
|
||||
output += utils.get_bytes_from_int(-1)
|
||||
|
||||
return output
|
||||
|
||||
def find_server(self, query_game, filter, fields, max_servers, game_name, challenge):
|
||||
# Get dictionary from master server list server.
|
||||
logger.log(logging.DEBUG, "Searching for server matching '%s' with the fields '%s'" % (filter, fields))
|
||||
|
||||
self.server_list = self.server_manager.find_servers(query_game, filter, fields, max_servers)._getvalue()
|
||||
|
||||
logger.log(logging.DEBUG, "Found server(s):")
|
||||
logger.log(logging.DEBUG, self.server_list)
|
||||
|
||||
if self.server_list == []:
|
||||
self.server_list.append({})
|
||||
|
||||
for _server in self.server_list:
|
||||
server = _server
|
||||
if len(server) > 0 and len(fields) > 0 and server['requested'] == {}:
|
||||
# If the requested fields weren't found then don't return a server.
|
||||
# This fixes a bug with Mario Kart DS.
|
||||
#print "Requested was empty"
|
||||
server = {}
|
||||
|
||||
# Generate binary server list data
|
||||
data = self.generate_server_list_data(self.address, fields, server)
|
||||
logger.log(logging.DEBUG, utils.pretty_print_hex(data))
|
||||
|
||||
# Encrypt data
|
||||
enc = gs_utils.EncTypeX()
|
||||
data = enc.encrypt(self.secret_key_list[game_name], challenge, data)
|
||||
|
||||
# Send to client
|
||||
self.transport.write(bytes(data))
|
||||
logger.log(logging.DEBUG, "Sent server list message to %s:%s..." % (self.address.host, self.address.port))
|
||||
@@ -1,374 +0,0 @@
|
||||
# Master server list server
|
||||
#
|
||||
# Basic idea:
|
||||
# The server listing does not need to be persistent, and it must be easily searchable for any unknown parameters.
|
||||
# So instead of using a SQL database, I've opted to create a server list database server which communicates between the
|
||||
# server browser and the qr server. The server list database will be stored in dictionaries as to allow dynamic columns
|
||||
# that can be easily searched. The main reason for this configuration is because it cannot be guaranteed what data
|
||||
# a game's server will required. For example, in addition to the common fields such as publicip, numplayers, dwc_pid, etc,
|
||||
# Lost Magic also uses fields such as LMname, LMsecN, LMrating, LMbtmode, and LMversion.
|
||||
#
|
||||
# It would be possible to create game-specific databases but this would be more of a hassle and less universal. It would
|
||||
# also be possible pickle a dictionary containing all of the fields and store it in a SQL database instead, but that
|
||||
# would require unpickling every server each time you want to match search queries which would cause overhead if there
|
||||
# are a lot of running servers. One trade off here is that we'll be using more memory by storing each server as a
|
||||
# dictionary in the memory instead of storing it in a SQL database.
|
||||
#
|
||||
# qr_server and server_browser both will act as clients to gs_server_database.
|
||||
# qr_server will send an add and/or delete to add or remove servers from the server list.
|
||||
# server_browser will send a request with the game name followed by optional search parameters to get a list of servers.
|
||||
|
||||
from multiprocessing.managers import BaseManager
|
||||
from multiprocessing import freeze_support
|
||||
import other.utils as utils
|
||||
|
||||
server_list = {}
|
||||
|
||||
class TokenType:
|
||||
UNKNOWN = 0
|
||||
FIELD = 1
|
||||
STRING = 2
|
||||
NUMBER = 3
|
||||
TOKEN = 4
|
||||
|
||||
def get_token(filter, i):
|
||||
# Complex example from Dungeon Explorer: Warriors of Ancient Arts
|
||||
# dwc_mver = 3 and dwc_pid != 474890913 and maxplayers = 2 and numplayers < 2 and dwc_mtype = 0 and dwc_mresv != dwc_pid and (MatchType='english')
|
||||
#
|
||||
# Digging into a few DS games, and these hardcoded search queries seem to be consistent between them:
|
||||
# %s = %d and %s != %u and maxplayers = %d and numplayers < %d and %s = %d and %s != %s
|
||||
# %s and (%s)
|
||||
# %s = %u
|
||||
#
|
||||
# It does not look like OR commands are (at least by default) supported, so for now they won't be implemented.
|
||||
#
|
||||
# Things that must be implemented:
|
||||
# - and operator
|
||||
# - integer comparisons
|
||||
# - string literals comparisons
|
||||
# - comparison between two fields
|
||||
# - comparison operators (<, >, =, !=)
|
||||
# - if not already available, maybe extend the comparison operators to include <= and >= just to be safe
|
||||
#
|
||||
# Things that won't be supported for now unless required:
|
||||
# - or operator
|
||||
# - grouping of operators, e.g.: (x or y) and (y or z)
|
||||
|
||||
start = i
|
||||
special_chars = "_"
|
||||
|
||||
token_type = TokenType.UNKNOWN
|
||||
|
||||
# Skip whitespace
|
||||
while i < len(filter) and filter[i].isspace():
|
||||
i += 1
|
||||
start += 1
|
||||
|
||||
if i < len(filter):
|
||||
if filter[i] == "(" or filter[i] == ")":
|
||||
i += 1
|
||||
token_type = TokenType.TOKEN
|
||||
|
||||
elif filter[i] == "&":
|
||||
i += 1
|
||||
token_type = TokenType.TOKEN
|
||||
|
||||
elif filter[i] == "=":
|
||||
i += 1
|
||||
token_type = TokenType.TOKEN
|
||||
|
||||
elif filter[i] == ">" or filter[i] == "<":
|
||||
i += 1
|
||||
token_type = TokenType.TOKEN
|
||||
|
||||
if i + 1 < len(filter) and filter[i+1] == "=":
|
||||
# >= or <=
|
||||
i += 1
|
||||
|
||||
elif i + 1 < len(filter) and filter[i] == "!" and filter[i + 1] == "=":
|
||||
i += 2
|
||||
token_type = TokenType.TOKEN
|
||||
|
||||
elif filter[i] == "'":
|
||||
token_type = TokenType.STRING
|
||||
|
||||
i += 1 # Skip quotation mark
|
||||
while i < len(filter) and filter[i] != "'":
|
||||
i += 1
|
||||
|
||||
if i < len(filter) and filter[i] == "'":
|
||||
i += 1 # Skip quotation mark
|
||||
|
||||
elif filter[i] == "\"":
|
||||
# I don't know if it's in the spec or not, but I added "" string literals as well just in case.
|
||||
token_type = TokenType.STRING
|
||||
|
||||
i += 1 # Skip quotation mark
|
||||
while i < len(filter) and filter[i] != "\"":
|
||||
i += 1
|
||||
|
||||
if i < len(filter) and filter[i] == "\"":
|
||||
i += 1 # Skip quotation mark
|
||||
|
||||
elif filter[i].isalnum() or filter[i] in special_chars:
|
||||
# Get whole numbers or words
|
||||
if filter[i].isdigit():
|
||||
token_type = TokenType.NUMBER
|
||||
if filter[i].isalpha():
|
||||
token_type = TokenType.FIELD
|
||||
|
||||
while i < len(filter) and (filter[i].isalnum() or filter[i] in special_chars) and filter[i] not in "!=>< ":
|
||||
i += 1
|
||||
|
||||
if token_type == TokenType.STRING:
|
||||
token = filter[start + 1:i - 1]
|
||||
else:
|
||||
token = filter[start:i]
|
||||
|
||||
if token_type == TokenType.NUMBER:
|
||||
token = int(token)
|
||||
|
||||
return token, i, token_type
|
||||
|
||||
def match(filter, i, search, case_insensitive=False):
|
||||
start = i
|
||||
found_match = False
|
||||
|
||||
# Get the next token
|
||||
token, i, _ = get_token(filter, i)
|
||||
|
||||
if case_insensitive == True:
|
||||
token = token.lower()
|
||||
search = search.lower()
|
||||
|
||||
# If the token isn't the same as what we're searching for, don't move forward.
|
||||
if token != search:
|
||||
i = start
|
||||
else:
|
||||
found_match = True
|
||||
|
||||
return found_match, i
|
||||
|
||||
def parse_filter(filter):
|
||||
ops = []
|
||||
values = []
|
||||
i = 0
|
||||
found_match = True
|
||||
filter_count = 0
|
||||
|
||||
# Continue while there's a connecting "and".
|
||||
while found_match and i < len(filter):
|
||||
found_match_bracket, i = match(filter, i, "(")
|
||||
|
||||
if found_match_bracket:
|
||||
a, b, filter, f = parse_filter(filter[i:])
|
||||
found_closing_match_bracket, i = match(filter, i, ")")
|
||||
found_match = found_closing_match_bracket
|
||||
a.reverse()
|
||||
b.reverse()
|
||||
ops = a + ops
|
||||
values = b + values
|
||||
filter_count += f
|
||||
else:
|
||||
l, i, token_type = get_token(filter, i)
|
||||
|
||||
if token_type == TokenType.FIELD and l.lower() == "and":
|
||||
filter_count += 1
|
||||
continue
|
||||
|
||||
elif l == "(" or l == ")":
|
||||
continue
|
||||
|
||||
if token_type == TokenType.TOKEN:
|
||||
ops.append(l)
|
||||
else:
|
||||
values.append({'value': l, 'type': token_type})
|
||||
|
||||
return ops, values, filter[i:], filter_count
|
||||
|
||||
def find_servers(gameid, filter, fields, max_count):
|
||||
servers = []
|
||||
|
||||
if gameid in server_list:
|
||||
ops_parsed, values_parsed, _, filter_count = parse_filter(filter)
|
||||
|
||||
# In the case that there were no "AND" commands, check to make sure there was at least one other command
|
||||
if len(ops_parsed) > 0:
|
||||
filter_count += 1
|
||||
|
||||
if max_count <= 0:
|
||||
max_count = 1
|
||||
|
||||
# Generate a list of servers that match the given criteria.
|
||||
for server in server_list[gameid]:
|
||||
ops = ops_parsed
|
||||
values = values_parsed
|
||||
|
||||
if len(servers) > max_count and max_count != -1:
|
||||
break
|
||||
|
||||
matched_filters = 0
|
||||
while len(ops) > 0:
|
||||
op = ops.pop()
|
||||
r = None
|
||||
l = None
|
||||
|
||||
if len(values) != 0:
|
||||
r = values.pop()
|
||||
if len(values) != 0:
|
||||
l = values.pop()
|
||||
|
||||
if r == None or l == None:
|
||||
break
|
||||
|
||||
lval = l['value']
|
||||
rval = r['value']
|
||||
|
||||
if l['type'] == TokenType.FIELD and l['value'] in server:
|
||||
lval = server[l['value']]
|
||||
_, _, l['type'] = get_token(lval, 0)
|
||||
|
||||
if l['type'] == TokenType.NUMBER:
|
||||
lval = int(lval)
|
||||
|
||||
if r['type'] == TokenType.FIELD and r['value'] in server:
|
||||
rval = server[r['value']]
|
||||
_, _, r['type'] = get_token(rval, 0)
|
||||
|
||||
if r['type'] == TokenType.NUMBER:
|
||||
rval = int(rval)
|
||||
|
||||
match = False
|
||||
if op == "=" and lval == rval:
|
||||
match = True
|
||||
elif op == "!=" and lval != rval:
|
||||
match = True
|
||||
elif op == "<" and lval < rval:
|
||||
match = True
|
||||
elif op == ">" and lval > rval:
|
||||
match = True
|
||||
elif op == ">=" and lval >= rval:
|
||||
match = True
|
||||
elif op == "<=" and lval <= rval:
|
||||
match = True
|
||||
elif op == "&":
|
||||
values.append({ 'value': int(lval & rval), 'type': TokenType.NUMBER})
|
||||
|
||||
if match == True:
|
||||
#print "Matched: %s %s %s" % (l['value'], op, r['value'])
|
||||
matched_filters += 1
|
||||
elif op != "&": # & doesn't need to be matched, so don't display a message for it
|
||||
#print "Not matched: %s %s %s" % (l['value'], op, r['value'])
|
||||
pass
|
||||
|
||||
#print "Matched %d/%d" % (matched_filters, filter_count)
|
||||
|
||||
# Add the server if everything was matched
|
||||
if matched_filters == filter_count:
|
||||
# Create a result with only the fields requested
|
||||
result = {}
|
||||
|
||||
if 'localip0' in server:
|
||||
# localip1, localip2, ... are possible, but are they ever used?
|
||||
# Small chance this might cause an issue later.
|
||||
result['localip0'] = server['localip0']
|
||||
|
||||
if 'localport' in server:
|
||||
result['localport'] = server['localport']
|
||||
|
||||
if 'localport' in server:
|
||||
result['localport'] = server['localport']
|
||||
|
||||
if 'natneg' in server:
|
||||
result['natneg'] = server['natneg']
|
||||
|
||||
if 'publicip' in server:
|
||||
result['publicip'] = server['publicip']
|
||||
|
||||
if 'publicport' in server:
|
||||
result['publicport'] = server['publicport']
|
||||
|
||||
if '__session__' in server:
|
||||
result['__session__'] = server['__session__']
|
||||
|
||||
requested = {}
|
||||
for field in fields:
|
||||
if not field in result:
|
||||
if field in server:
|
||||
requested[field] = server[field]
|
||||
else:
|
||||
# Return a dummy value. What's the normal behavior of the real server in this case?
|
||||
requested[field] = ""
|
||||
|
||||
|
||||
result['requested'] = requested
|
||||
servers.append(result)
|
||||
|
||||
return servers
|
||||
|
||||
def update_server_list(gameid, session, value):
|
||||
# Make sure the user isn't hosting multiple servers or there isn't some left over server information that
|
||||
# never got handled properly (game crashed, etc).
|
||||
delete_server(gameid, session)
|
||||
|
||||
# If the game doesn't exist already, create a new list.
|
||||
if not gameid in server_list:
|
||||
server_list[gameid] = []
|
||||
|
||||
# Add new server
|
||||
value['__session__'] = session
|
||||
|
||||
utils.print_log("Added %s to the server list for %s" % (value, gameid))
|
||||
server_list[gameid].append(value)
|
||||
print "%s servers: %d" % (gameid, len(server_list[gameid]))
|
||||
|
||||
def delete_server(gameid, session):
|
||||
if not gameid in server_list:
|
||||
# Nothing to do if no servers for that game even exist.
|
||||
return
|
||||
|
||||
# Remove all servers hosted by the given session id.
|
||||
count = len(server_list[gameid])
|
||||
server_list[gameid] = [x for x in server_list[gameid] if x['__session__'] != session]
|
||||
count -= len(server_list[gameid])
|
||||
print "Deleted %d %s servers where session = %d" % (count, gameid, session)
|
||||
|
||||
def find_server_by_address(ip, port, gameid = None):
|
||||
if gameid == None:
|
||||
# Search all servers
|
||||
for gameid in server_list:
|
||||
for server in server_list[gameid]:
|
||||
print server
|
||||
if server['publicip'] == ip and server['publicport'] == str(port):
|
||||
return server
|
||||
else:
|
||||
for server in server_list[gameid]:
|
||||
print server
|
||||
if server['publicip'] == ip and server['publicport'] == str(port):
|
||||
return server
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class GamespyServerDatabase(BaseManager):
|
||||
pass
|
||||
|
||||
def start_server():
|
||||
address = ("127.0.0.1", 27500)
|
||||
password = ""
|
||||
|
||||
GamespyServerDatabase.register("get_server_list", callable=lambda:server_list)
|
||||
GamespyServerDatabase.register("find_servers", callable=find_servers)
|
||||
GamespyServerDatabase.register("find_server_by_address", callable=find_server_by_address)
|
||||
GamespyServerDatabase.register("update_server_list", callable=update_server_list)
|
||||
GamespyServerDatabase.register("delete_server", callable=delete_server)
|
||||
|
||||
print "Started server on %s:%d..." % (address[0], address[1])
|
||||
|
||||
manager = GamespyServerDatabase(address = address, authkey = password)
|
||||
server = manager.get_server()
|
||||
server.serve_forever()
|
||||
|
||||
if __name__ == '__main__':
|
||||
freeze_support()
|
||||
start_server()
|
||||
51
master_server.py
Normal file
51
master_server.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from gamespy_player_search_server import GameSpyPlayerSearchServer
|
||||
from gamespy_profile_server import GameSpyProfileServer
|
||||
from gamespy_backend_server import GameSpyBackendServer
|
||||
from gamespy_natneg_server import GameSpyNatNegServer
|
||||
from gamespy_qr_server import GameSpyQRServer
|
||||
from gamespy_server_browser_server import GameSpyServerBrowserServer
|
||||
|
||||
import threading
|
||||
|
||||
def start_backend_server():
|
||||
backend_server = GameSpyBackendServer()
|
||||
backend_server.start()
|
||||
|
||||
def start_qr_server():
|
||||
qr_server = GameSpyQRServer()
|
||||
qr_server.start()
|
||||
|
||||
def start_profile_server():
|
||||
profile_server = GameSpyProfileServer()
|
||||
profile_server.start()
|
||||
|
||||
def start_player_search_server():
|
||||
player_search_server = GameSpyPlayerSearchServer()
|
||||
player_search_server.start()
|
||||
|
||||
def start_server_browser_server():
|
||||
server_browser_server = GameSpyServerBrowserServer()
|
||||
server_browser_server.start()
|
||||
|
||||
def start_natneg_server():
|
||||
natneg_server = GameSpyNatNegServer()
|
||||
natneg_server.start()
|
||||
|
||||
if __name__ == "__main__":
|
||||
backend_server_thread = threading.Thread(target=start_backend_server)
|
||||
backend_server_thread.start()
|
||||
|
||||
qr_server_thread = threading.Thread(target=start_qr_server)
|
||||
qr_server_thread.start()
|
||||
|
||||
profile_server_thread = threading.Thread(target=start_profile_server)
|
||||
profile_server_thread.start()
|
||||
|
||||
player_search_server_thread = threading.Thread(target=start_player_search_server)
|
||||
player_search_server_thread.start()
|
||||
|
||||
server_browser_server_thread = threading.Thread(target=start_server_browser_server)
|
||||
server_browser_server_thread.start()
|
||||
|
||||
natneg_server_thread = threading.Thread(target=start_natneg_server)
|
||||
natneg_server_thread.start()
|
||||
111
natneg_server.py
111
natneg_server.py
@@ -1,111 +0,0 @@
|
||||
# Server emulator for *.available.gs.nintendowifi.net and *.master.gs.nintendowifi.net
|
||||
# Query and Reporting: http://docs.poweredbygamespy.com/wiki/Query_and_Reporting_Overview
|
||||
|
||||
import socket
|
||||
import struct
|
||||
import gamespy.gs_utility as gs_utils
|
||||
import other.utils as utils
|
||||
from multiprocessing.managers import BaseManager
|
||||
|
||||
session_list = {}
|
||||
|
||||
secret_key_list = gs_utils.generate_secret_keys("gslist.cfg")
|
||||
|
||||
# Start QR server
|
||||
address = ('0.0.0.0', 27901) # accessible to outside connections (use this if you don't know what you're doing)
|
||||
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.bind(address)
|
||||
|
||||
utils.print_log("Server is now listening on %s:%s..." % (address[0], address[1]))
|
||||
|
||||
while 1:
|
||||
recv_data, addr = s.recvfrom(2048)
|
||||
|
||||
print "Connection from %s:%d..." % (addr[0], addr[1])
|
||||
|
||||
# Make sure it's a legal packet
|
||||
if recv_data[0:6] != bytearray([0xfd, 0xfc, 0x1e, 0x66, 0x6a, 0xb2]):
|
||||
continue
|
||||
|
||||
session_id = struct.unpack("<I", recv_data[8:12])[0]
|
||||
session_id_raw = recv_data[8:12]
|
||||
|
||||
# Handle commands
|
||||
if recv_data[7] == '\x00':
|
||||
utils.print_log("Received initialization from %s:%s..." % (addr[0], addr[1]))
|
||||
|
||||
gameid = utils.get_string(recv_data, 0x16)
|
||||
client_id = "%02x" % ord(recv_data[13])
|
||||
|
||||
if gameid not in session_list:
|
||||
session_list[gameid] = {}
|
||||
if session_id not in session_list[gameid]:
|
||||
session_list[gameid][session_id] = {}
|
||||
if client_id not in session_list[gameid][session_id]:
|
||||
session_list[gameid][session_id][client_id] = { 'connected': False, 'addr': '' }
|
||||
|
||||
session_list[gameid][session_id][client_id]['addr'] = addr
|
||||
clients = len(session_list[gameid][session_id])
|
||||
#if client_id in session_list[gameid][session_id]:
|
||||
# clients -= 1
|
||||
|
||||
if clients > 0:
|
||||
# Someone else is waiting to connect, send message
|
||||
for client in session_list[gameid][session_id]:
|
||||
if session_list[gameid][session_id][client]['connected'] == True or client == client_id:
|
||||
continue
|
||||
|
||||
output = bytearray(recv_data[0:12])
|
||||
if client == client_id:
|
||||
output += bytearray(recv_data[15:15+4+2]) # IP Address and port
|
||||
else:
|
||||
output += bytearray([int(x) for x in addr[0].split('.')])
|
||||
output += utils.get_bytes_from_short_be(addr[1])
|
||||
|
||||
output += bytearray([0x42, 0x00]) # Unknown, always seems to be \x42\x00
|
||||
output[7] = 0x05
|
||||
s.sendto(output, (session_list[gameid][session_id][client]['addr']))
|
||||
|
||||
print "Sent connection request to %s:%d..." % (session_list[gameid][session_id][client]['addr'][0], session_list[gameid][session_id][client]['addr'][1])
|
||||
utils.print_hex(output)
|
||||
print ""
|
||||
#session_list[gameid][session_id][client_id]['connected'] = True
|
||||
|
||||
output = bytearray(recv_data[0:14])
|
||||
output += bytearray([0xff, 0xff, 0x6d, 0x16, 0xb5, 0x7d, 0xea ]) # Checked with Tetris DS, Mario Kart DS, and Metroid Prime Hunters, and this seems to be the standard response to 0x00
|
||||
output[7] = 0x01 # Initialization response
|
||||
s.sendto(output, addr)
|
||||
|
||||
elif recv_data[7] == '\x06': # Was able to connect
|
||||
client_id = "%02x" % ord(recv_data[13])
|
||||
utils.print_log("Received connected command from %s:%s..." % (addr[0], addr[1]))
|
||||
|
||||
if gameid not in session_list:
|
||||
pass
|
||||
if session_id not in session_list[gameid]:
|
||||
pass
|
||||
if client_id not in session_list[gameid][session_id]:
|
||||
pass
|
||||
|
||||
#session_list[gameid][session_id][client_id]['connected'] = True
|
||||
|
||||
elif recv_data[7] == '\x0d':
|
||||
client_id = "%02x" % ord(recv_data[13])
|
||||
utils.print_log("Received report command from %s:%s..." % (addr[0], addr[1]))
|
||||
|
||||
utils.print_hex(bytearray(recv_data))
|
||||
|
||||
output[7] = 0x0e # Report response
|
||||
s.sendto(output, addr)
|
||||
|
||||
else: # Was able to connect
|
||||
utils.print_log("Received unknown command %02x from %s:%s..." % (ord(recv_data[7]), addr[0], addr[1]))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import random
|
||||
import time
|
||||
import sys
|
||||
import logging
|
||||
|
||||
def generate_random_str(len):
|
||||
return ''.join(random.choice("abcdefghjiklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890") for _ in range(len))
|
||||
@@ -65,10 +66,6 @@ def base32_decode(str, reverse = False):
|
||||
|
||||
return orig
|
||||
|
||||
# For server logging
|
||||
def print_log(text):
|
||||
print "[%s] %s" % (time.strftime("%c"), text)
|
||||
print ""
|
||||
|
||||
# Number routines
|
||||
# I'm not sure what the pythonic way to do this is, so I'm making the code explicit by giving myself functions to
|
||||
@@ -135,24 +132,58 @@ def get_bytes_from_int(num):
|
||||
def get_bytes_from_int_be(num):
|
||||
return get_bytes_from_num(num, 4, True)
|
||||
|
||||
|
||||
# For server logging
|
||||
def create_logger(loggername, filename = None, level = logging.DEBUG):
|
||||
logging.addLevelName(-1, "TRACE")
|
||||
|
||||
format= "[%(asctime)s | " + loggername + "] %(message)s"
|
||||
date_format = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
#logging.basicConfig(format=format, datefmt=date_format)
|
||||
logger = logging.getLogger(loggername)
|
||||
logger.setLevel(level)
|
||||
|
||||
# Only needed when logging.basicConfig isn't set.
|
||||
console_logger = logging.StreamHandler()
|
||||
console_logger.setFormatter(logging.Formatter(format, datefmt=date_format))
|
||||
logger.addHandler(console_logger)
|
||||
|
||||
if filename != None:
|
||||
file_logger = logging.FileHandler(filename)
|
||||
file_logger.setFormatter(logging.Formatter(format, datefmt=date_format))
|
||||
logger.addHandler(file_logger)
|
||||
|
||||
return logger
|
||||
|
||||
def print_hex(data, cols = 16):
|
||||
print pretty_print_hex(data, cols)
|
||||
|
||||
def pretty_print_hex(orig_data, cols = 16):
|
||||
data = bytearray(orig_data)
|
||||
output = "\n"
|
||||
|
||||
for i in range(len(data) / cols + 1):
|
||||
output += "%08x | " % (i * 16)
|
||||
|
||||
c = 0
|
||||
for x in range(cols):
|
||||
if (i * cols + x + 1) > len(data):
|
||||
break
|
||||
|
||||
print "%02x" % data[i * cols + x],
|
||||
output += "%02x " % data[i * cols + x]
|
||||
c += 1
|
||||
|
||||
c = cols - c
|
||||
sys.stdout.write(" " * (c * 3 + 1))
|
||||
output += " " * (c * 3 + 1)
|
||||
for x in range(cols):
|
||||
if (i * cols + x + 1) > len(data):
|
||||
break
|
||||
|
||||
if data[i * cols + x] < 0x21 or data[i * cols + x] >= 0x7f:
|
||||
sys.stdout.write(".")
|
||||
output += "."
|
||||
else:
|
||||
sys.stdout.write("%c" % data[i * cols + x])
|
||||
print ""
|
||||
output += "%c" % data[i * cols + x]
|
||||
output += "\n"
|
||||
|
||||
return output
|
||||
241
qr_server.py
241
qr_server.py
@@ -1,241 +0,0 @@
|
||||
# Server emulator for *.available.gs.nintendowifi.net and *.master.gs.nintendowifi.net
|
||||
# Query and Reporting: http://docs.poweredbygamespy.com/wiki/Query_and_Reporting_Overview
|
||||
|
||||
import socket
|
||||
import struct
|
||||
import gamespy.gs_utility as gs_utils
|
||||
import other.utils as utils
|
||||
from multiprocessing.managers import BaseManager
|
||||
|
||||
session_list = {}
|
||||
class Session(object):
|
||||
def __init__(self, address):
|
||||
self.session = ""
|
||||
self.challenge = ""
|
||||
self.secretkey = "" # Parse gslist.cfg later
|
||||
self.sent_challenge = False
|
||||
self.address = addr
|
||||
|
||||
|
||||
# Generate a dictionary "secret_key_list" containing the secret game keys associated with their game IDs.
|
||||
# The dictionary key will be the game's ID, and the value will be the secret key.
|
||||
secret_key_list = gs_utils.generate_secret_keys("gslist.cfg")
|
||||
utils.print_log("Generated list of secret game keys...")
|
||||
|
||||
# Initialize server list server connection
|
||||
class GamespyServerDatabase(BaseManager):
|
||||
pass
|
||||
|
||||
GamespyServerDatabase.register("update_server_list")
|
||||
GamespyServerDatabase.register("delete_server")
|
||||
|
||||
manager_address = ("127.0.0.1", 27500)
|
||||
manager_password = ""
|
||||
|
||||
server_manager = GamespyServerDatabase(address = manager_address, authkey= manager_password)
|
||||
server_manager.connect()
|
||||
|
||||
# Start QR server
|
||||
address = ('0.0.0.0', 27900) # accessible to outside connections (use this if you don't know what you're doing)
|
||||
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.bind(address)
|
||||
|
||||
utils.print_log("Server is now listening on %s:%s..." % (address[0], address[1]))
|
||||
|
||||
while 1:
|
||||
recv_data, addr = s.recvfrom(2048)
|
||||
|
||||
# Tetris DS overlay 10 @ 02144184 - Handle responses back to server
|
||||
# Tetris DS overlay 10 @ 02144184 - Handle responses back to server
|
||||
#
|
||||
# After some more packet inspection, it seems the format goes something like this:
|
||||
# - All server messages seem to always start with \xfe\xfd.
|
||||
# - The first byte from the client (or third byte from the server) is a command.
|
||||
# - Bytes 2 - 5 from the client is some kind of ID. This will have to be inspected later. I believe it's a
|
||||
# session-like ID because the number changes between connections. Copying the client's ID might be enough.
|
||||
#
|
||||
# The above was as guessed.
|
||||
# The code in Tetris DS (overlay 10) @ 0216E974 handles the network command creation.
|
||||
# R1 contains the command to be sent to the server.
|
||||
# R2 contains a pointer to some unknown integer that gets written after the command.
|
||||
#
|
||||
# - Commands
|
||||
# Commands range from 0x00 to 0x09 (for client only at least?) (Tetris DS overlay 10 @ 0216DDCC)
|
||||
#
|
||||
# CLIENT:
|
||||
# 0x01 - Response (Tetris DS overlay 10 @ 216DCA4)
|
||||
# Sends back base64 of RC4 encrypted string that was gotten from the server's 0x01.
|
||||
#
|
||||
# 0x03 - Send client state? (Tetris DS overlay 10 @ 216DA30)
|
||||
# Data sent:
|
||||
# 1) Loop for each localip available on the system, write as localip%d\x00(local ip)
|
||||
# 2) localport\x00(local port)
|
||||
# 3) natneg (either 0 or 1)
|
||||
# 4) ONLY IF STATE CHANGED: statechanged\x00(state) (Possible values: 0, 1, 2, 3)
|
||||
# 5) gamename\x00(game name)
|
||||
# 6) ONLY IF PUBLIC IP AND PORT ARE AVAILABLE: publicip\x00(public ip)
|
||||
# 7) ONLY IF PUBLIC IP AND PORT ARE AVAILABLE: publicport\x00(public port)
|
||||
#
|
||||
# if statechanged != 2:
|
||||
# Write various other data described here: http://docs.poweredbygamespy.com/wiki/Query_and_Reporting_Implementation
|
||||
#
|
||||
# 0x07 - Unknown, related to server's 0x06 (returns value sent from server)
|
||||
#
|
||||
# 0x08 - Keep alive? Sent after 0x03
|
||||
#
|
||||
# 0x09 - Availability check
|
||||
#
|
||||
# SERVER:
|
||||
# 0x01 - Unknown
|
||||
# Data sent:
|
||||
# 8 random ASCII characters (?) followed by the public IP and port of the client as a hex string
|
||||
#
|
||||
# 0x06 - Unknown
|
||||
# First 4 bytes is some kind of id? I believe it's a unique identifier for the data being sent,
|
||||
# seeing how the server can send the same IP information many times in a row. If the IP information has
|
||||
# already been parsed then it doesn't waste time handling it.
|
||||
#
|
||||
# After that is a "SBCM" section which is 0x14 bytes in total.
|
||||
# SBCM information gets parsed at 2141A0C in Tetris DS overlay 10.
|
||||
# Seems to contain IP address information.
|
||||
#
|
||||
# The SBCM seems to contain a little information that must be parsed before.
|
||||
# After the SBCM:
|
||||
# \x03\x00\x00\x00 - Always the same?
|
||||
# \x01 - Found player?
|
||||
# \x04 - Unknown
|
||||
# (2 bytes) - Unknown. Port?
|
||||
# (4 bytes) - Player's IP
|
||||
# (4 bytes) - Unknown. Some other IP? Remote server IP?
|
||||
# \x00\x00\x00\x00 - Unknown but seems to get checked
|
||||
#
|
||||
# Another SBCM, after a player has been found and attempting to start a game:
|
||||
# \x03\x00\x00\x00 - Always the same?
|
||||
# \x05 - Connecting to player?
|
||||
# \x00 - Unknown
|
||||
# (2 bytes) - Unknown. Port? Same as before.
|
||||
# (4 bytes) - Player's IP
|
||||
# (4 bytes) - Unknown. Some other IP? Remote server IP?
|
||||
#
|
||||
# 0x0a - Response to 0x01
|
||||
# Gets sent after receiving 0x01 from the client. So, server 0x01 -> client 0x01 -> server 0x0a.
|
||||
# Has no other data besides the client ID.
|
||||
#
|
||||
# - \xfd\xfc commands get passed directly between the other player(s)?
|
||||
#
|
||||
#
|
||||
# Open source version of GameSpy found here: https://github.com/sfcspanky/Openspy-Core/tree/master/qr
|
||||
# Use as reference.
|
||||
|
||||
session_id = struct.unpack("<I", recv_data[1:5])[0]
|
||||
session_id_raw = recv_data[1:5]
|
||||
if session_id not in session_list:
|
||||
# Found a new session, add to session list
|
||||
session_list[session_id] = Session(addr)
|
||||
|
||||
# Handle commands
|
||||
if recv_data[0] == '\x00': # Query
|
||||
utils.print_log("NOT IMPLEMENTED! Received query from %s:%s... %s" % (addr[0], addr[1], recv_data[5:]))
|
||||
|
||||
elif recv_data[0] == '\x01': # Challenge
|
||||
utils.print_log("Received challenge from %s:%s... %s" % (addr[0], addr[1], recv_data[5:]))
|
||||
|
||||
# Prepare the challenge sent from the server to be compared
|
||||
challenge = gs_utils.prepare_rc4_base64(session_list[session_id].secretkey, session_list[session_id].challenge)
|
||||
|
||||
# Compare challenge
|
||||
client_challenge = recv_data[5:-1]
|
||||
if client_challenge == challenge:
|
||||
# Challenge succeeded
|
||||
session_list[session_id].sent_challenge = True
|
||||
|
||||
# Handle successful challenge stuff here
|
||||
packet = bytearray([0xfe, 0xfd, 0x0a]) # Send client registered command
|
||||
packet.extend(session_id_raw) # Get the session ID
|
||||
s.sendto(packet, addr)
|
||||
utils.print_log("Sent client registered to %s:%s..." % (addr[0], addr[1]))
|
||||
|
||||
# TODO: Send buddy list to client on successful connection.
|
||||
|
||||
elif recv_data[0] == '\x02': # Echo
|
||||
utils.print_log("NOT IMPLEMENTED! Received echo from %s:%s... %s" % (addr[0], addr[1], recv_data[5:]))
|
||||
|
||||
elif recv_data[0] == '\x03': # Heartbeat
|
||||
data = recv_data[5:]
|
||||
utils.print_log("Received heartbeat from %s:%s... %s" % (addr[0], addr[1], data))
|
||||
|
||||
# Parse information from heartbeat here
|
||||
d = data.rstrip('\0').split('\0')
|
||||
|
||||
# It may be safe to ignore "unknown" keys because the proper key names get filled in later...
|
||||
k = {}
|
||||
i = 0
|
||||
while i < len(d):
|
||||
print "%s = %s" % (d[i], d[i+1])
|
||||
k[d[i]] = d[i+1]
|
||||
i += 2
|
||||
|
||||
if "gamename" in k:
|
||||
session_list[session_id].secretkey = secret_key_list[k['gamename']]
|
||||
#print "Got secret key %s for %s" % (session_list[session_id].secretkey, k['gamename'])
|
||||
|
||||
if session_list[session_id].sent_challenge == False:
|
||||
addr_hex = ''.join(["%02X" % int(x) for x in addr[0].split('.')])
|
||||
port_hex = "%04X" % int(addr[1])
|
||||
server_challenge = utils.generate_random_str(8) + addr_hex + port_hex
|
||||
|
||||
session_list[session_id].challenge = server_challenge
|
||||
|
||||
packet = bytearray([0xfe, 0xfd, 0x01]) # Send challenge command
|
||||
packet.extend(session_id_raw) # Get the session ID
|
||||
packet.extend(server_challenge)
|
||||
packet.extend('\x00')
|
||||
|
||||
s.sendto(packet, addr)
|
||||
utils.print_log("Sent challenge to %s:%s..." % (addr[0], addr[1]))
|
||||
|
||||
if "statechanged" in k:
|
||||
if k['statechanged'] == "1": # Create server
|
||||
if k['publicport'] != "0" and k['publicip'] != "0" and k['maxplayers'] != "0":
|
||||
# dwc_mtype controls what kind of server query we're looking for.
|
||||
# dwc_mtype = 0 is used when looking for a matchmaking game.
|
||||
# dwc_mtype = 1 is unknown.
|
||||
# dwc_mtype = 2 is used when hosting a friends only game (possibly other uses too).
|
||||
# dwc_mtype = 3 is used when looking for a friends only game (possibly other uses too).
|
||||
|
||||
# Some memory could be saved by clearing out any unwanted fields from k before sending.
|
||||
server_manager.update_server_list(k['gamename'] , session_id, k)
|
||||
elif k['statechanged'] == "2": # Close server
|
||||
server_manager.delete_server(k['gamename'] , session_id)
|
||||
#session_list.pop(session_id)
|
||||
|
||||
|
||||
elif recv_data[0] == '\x04': # Add Error
|
||||
utils.print_log("NOT IMPLEMENTED! Received add error from %s:%s... %s" % (addr[0], addr[1], recv_data[5:]))
|
||||
|
||||
elif recv_data[0] == '\x05': # Echo Response
|
||||
utils.print_log("NOT IMPLEMENTED! Received echo response from %s:%s... %s" % (addr[0], addr[1], recv_data[5:]))
|
||||
|
||||
elif recv_data[0] == '\x06': # Client Message
|
||||
utils.print_log("NOT IMPLEMENTED! Received echo from %s:%s... %s" % (addr[0], addr[1], recv_data[5:]))
|
||||
|
||||
elif recv_data[0] == '\x07': # Client Message Ack
|
||||
utils.print_log("NOT IMPLEMENTED! Received client message ack from %s:%s... %s" % (addr[0], addr[1], recv_data[5:]))
|
||||
|
||||
elif recv_data[0] == '\x08': # Keep Alive
|
||||
utils.print_log("Received keep alive from %s:%s..." % (addr[0], addr[1]))
|
||||
|
||||
elif recv_data[0] == '\x09': # Available
|
||||
# Availability check only sent to *.available.gs.nintendowifi.net
|
||||
utils.print_log("Received availability request for '%s' from %s:%s..." % (recv_data[5: -1], addr[0], addr[1]))
|
||||
|
||||
s.sendto(bytearray([0xfe, 0xfd, 0x09, 0x00, 0x00, 0x00, 0x00]), addr)
|
||||
|
||||
elif recv_data[0] == '\x0a': # Client Registered
|
||||
# Only sent to client, never received?
|
||||
utils.print_log("NOT IMPLEMENTED! Received client registered from %s:%s... %s" % (addr[0], addr[1], recv_data[5:]))
|
||||
|
||||
else:
|
||||
utils.print_log(
|
||||
"Unknown request from %s:%s: %s" % (addr[0], addr[1], [elem.encode("hex") for elem in recv_data]))
|
||||
@@ -1,300 +0,0 @@
|
||||
# I found an open source implemention of this exact server I'm trying to emulate here: https://github.com/sfcspanky/Openspy-Core/blob/master/serverbrowsing/
|
||||
# Use as reference later.
|
||||
|
||||
from twisted.internet.protocol import Factory
|
||||
from twisted.internet.endpoints import serverFromString
|
||||
from twisted.protocols.basic import LineReceiver
|
||||
from twisted.internet import reactor
|
||||
|
||||
import socket
|
||||
import ctypes
|
||||
import time
|
||||
from threading import Thread
|
||||
|
||||
import gamespy.gs_utility as gs_utils
|
||||
import other.utils as utils
|
||||
|
||||
from multiprocessing.managers import BaseManager
|
||||
|
||||
def get_game_id(data):
|
||||
game_id = data[5: -1]
|
||||
return game_id
|
||||
|
||||
def get_server_list(game, filter, fields, max_count):
|
||||
results = server_manager.find_servers(game, filter, fields, max_count)
|
||||
return results
|
||||
|
||||
class ServerListFlags:
|
||||
UNSOLICITED_UDP_FLAG = 1
|
||||
PRIVATE_IP_FLAG = 2
|
||||
CONNECT_NEGOTIATE_FLAG = 4
|
||||
ICMP_IP_FLAG = 8
|
||||
NONSTANDARD_PORT_FLAG = 16
|
||||
NONSTANDARD_PRIVATE_PORT_FLAG = 32
|
||||
HAS_KEYS_FLAG = 64
|
||||
HAS_FULL_RULES_FLAG = 128
|
||||
|
||||
def generate_server_list_data(address, fields, server_info):
|
||||
output = bytearray()
|
||||
|
||||
# Write the address
|
||||
output += bytearray([int(x) for x in address.host.split('.')])
|
||||
|
||||
# Write the port
|
||||
output += utils.get_bytes_from_short_be(address.port)
|
||||
|
||||
#if len(server_info) > 0:
|
||||
if True:
|
||||
# Write number of fields that will be returned.
|
||||
key_count = len(fields)
|
||||
output += utils.get_bytes_from_short(key_count)
|
||||
|
||||
if key_count != len(fields):
|
||||
# For some reason we didn't get all of the expected data.
|
||||
print "key_count[%d] != len(fields)[%d]" % (key_count, len(fields))
|
||||
print fields
|
||||
|
||||
flags_buffer = bytearray()
|
||||
|
||||
# Write the fields
|
||||
for field in fields:
|
||||
output += bytearray(field) + '\0\0'
|
||||
|
||||
# Start server loop here instead of including all of the fields and stuff again
|
||||
flags = 0
|
||||
if len(server_info) != 0:
|
||||
flags |= ServerListFlags.HAS_KEYS_FLAG
|
||||
|
||||
if "natneg" in server_info:
|
||||
flags |= ServerListFlags.CONNECT_NEGOTIATE_FLAG
|
||||
|
||||
flags_buffer += utils.get_bytes_from_int(int(server_info['publicip']))
|
||||
|
||||
flags |= ServerListFlags.NONSTANDARD_PORT_FLAG
|
||||
flags_buffer += utils.get_bytes_from_short_be(int(server_info['publicport']))
|
||||
|
||||
if "localip0" in server_info:
|
||||
flags |= ServerListFlags.PRIVATE_IP_FLAG
|
||||
flags_buffer += bytearray([int(x) for x in server_info['localip0'].split('.')])
|
||||
|
||||
if "localport" in server_info:
|
||||
flags |= ServerListFlags.NONSTANDARD_PRIVATE_PORT_FLAG
|
||||
flags_buffer += utils.get_bytes_from_short_be(int(server_info['localport']))
|
||||
|
||||
flags |= ServerListFlags.ICMP_IP_FLAG
|
||||
flags_buffer += bytearray([int(x) for x in "0.0.0.0".split('.')])
|
||||
|
||||
output += bytearray([flags & 0xff])
|
||||
output += flags_buffer
|
||||
|
||||
if (flags & ServerListFlags.HAS_KEYS_FLAG):
|
||||
# Write data for associated fields
|
||||
for field in fields:
|
||||
output += '\xff' + bytearray(server_info['requested'][field]) + '\0'
|
||||
|
||||
output += '\0'
|
||||
output += utils.get_bytes_from_int(-1)
|
||||
|
||||
return output
|
||||
|
||||
class Session(LineReceiver):
|
||||
def __init__(self, addr):
|
||||
self.setRawMode() # We're dealing with binary data so set to raw mode
|
||||
self.addr = addr
|
||||
self.forward_to_client = False
|
||||
self.forward_client = ()
|
||||
|
||||
def rawDataReceived(self, data):
|
||||
# First 2 bytes are the packet size.
|
||||
#
|
||||
# Third byte is the command byte.
|
||||
# According to Openspy-Core:
|
||||
# 0x00 - Server list request
|
||||
# 0x01 - Server info request
|
||||
# 0x02 - Send message request
|
||||
# 0x03 - Keep alive reply
|
||||
# 0x04 - Map loop request (?)
|
||||
# 0x05 - Player search request
|
||||
#
|
||||
# For Tetris DS, at the very least 0x00 and 0x02 need to be implemented.
|
||||
|
||||
if self.forward_to_client:
|
||||
self.forward_to_client = False
|
||||
|
||||
# Find session id of server
|
||||
# Iterate through the list of servers sent to the client and match by IP and port.
|
||||
# Is there a better way to determine this information?
|
||||
ip = str(ctypes.c_int32(utils.get_int(bytearray([int(x) for x in self.forward_client[0].split('.')]), 0)).value)
|
||||
print "Trying to send message to %s:%d..." % (self.forward_client[0], self.forward_client[1])
|
||||
utils.print_hex(bytearray(data))
|
||||
|
||||
# Get server based on ip/port
|
||||
server = server_manager.find_server_by_address(ip, self.forward_client[1])._getvalue()
|
||||
print server
|
||||
|
||||
if server == None:
|
||||
pass
|
||||
|
||||
#print "%s %s" % (ip, server['publicip'])
|
||||
if server['publicip'] == ip and server['publicport'] == str(self.forward_client[1]):
|
||||
# Send command to server to get it to connect to natneg
|
||||
natneg_session = int(utils.generate_random_hex_str(8), 16) # Quick and lazy way to get a random 32bit integer. Replace with something else late.r
|
||||
|
||||
output = bytearray([0xfe, 0xfd, 0x06])
|
||||
output += utils.get_bytes_from_int(server['__session__'])
|
||||
output += bytearray(utils.get_bytes_from_int(natneg_session))
|
||||
output += bytearray(data)
|
||||
|
||||
client_s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
client_s.sendto(output, self.forward_client)
|
||||
utils.print_log("Forwarded data to %s:%s..." % (self.forward_client[0], self.forward_client[1]))
|
||||
return
|
||||
|
||||
if data[2] == '\x00': # Server list request
|
||||
utils.print_log("Received server list request from %s:%s..." % (self.addr.host, self.addr.port))
|
||||
|
||||
# This code is so... not python. The C programmer in me is coming out strong.
|
||||
# TODO: Rewrite this section later?
|
||||
idx = 3
|
||||
list_version = ord(data[idx])
|
||||
idx += 1
|
||||
encoding_version = ord(data[idx])
|
||||
idx += 1
|
||||
game_version = utils.get_int(data, idx)
|
||||
idx += 4
|
||||
|
||||
query_game = utils.get_string(data, idx)
|
||||
idx += len(query_game) + 1
|
||||
game_name = utils.get_string(data, idx)
|
||||
idx += len(game_name) + 1
|
||||
|
||||
challenge = data[idx:idx+8]
|
||||
idx += 8
|
||||
|
||||
filter = utils.get_string(data, idx)
|
||||
idx += len(filter) + 1
|
||||
fields = utils.get_string(data, idx)
|
||||
idx += len(fields) + 1
|
||||
|
||||
options = utils.get_int_be(data, idx)
|
||||
idx += 4
|
||||
|
||||
source_ip = 0
|
||||
max_servers = 0
|
||||
|
||||
ALTERNATE_SOURCE_IP = 0x08
|
||||
LIMIT_RESULT_COUNT = 0x80
|
||||
if (options & LIMIT_RESULT_COUNT):
|
||||
max_servers = utils.get_int(data, idx)
|
||||
elif (options & ALTERNATE_SOURCE_IP):
|
||||
source_ip = utils.get_int(data, idx)
|
||||
|
||||
if '\\' in fields:
|
||||
fields = [x for x in fields.split('\\') if x and not x.isspace()]
|
||||
|
||||
#print "%02x %02x %08x" % (list_version, encoding_version, game_version)
|
||||
#print "%s" % query_game
|
||||
print "%s" % game_name
|
||||
#print "%s" % challenge
|
||||
print "%s" % filter
|
||||
print "%s" % fields
|
||||
|
||||
#print "%08x" % options
|
||||
#print "%d %08x" % (max_servers, source_ip)
|
||||
|
||||
# Requesting ip and port of client, not server
|
||||
if filter == "" or fields == "":
|
||||
output = bytearray([int(x) for x in self.addr.host.split('.')])
|
||||
output += utils.get_bytes_from_short_be(self.addr.port)
|
||||
self.transport.write(bytes(output))
|
||||
print "Responding with own IP and port..."
|
||||
utils.print_hex(output)
|
||||
else:
|
||||
self.find_server(query_game, filter, fields, max_servers, game_name, challenge)
|
||||
|
||||
|
||||
|
||||
elif data[2] == '\x02': # Send message request
|
||||
dest_addr = '.'.join(["%d" % ord(x) for x in data[3:7]])
|
||||
dest_port = utils.get_short_be(data, 7) # What's the pythonic way to do this? unpack?
|
||||
dest = (dest_addr, dest_port)
|
||||
|
||||
utils.print_log("Received send message request from %s:%s to %s:%d..." % (self.addr.host, self.addr.port, dest_addr, dest_port))
|
||||
utils.print_hex(bytearray(data))
|
||||
|
||||
self.forward_to_client = True
|
||||
self.forward_client = dest
|
||||
|
||||
elif data[2] == '\x03': # Keep alive reply
|
||||
utils.print_log("Received keep alive from %s:%s..." % (self.addr.host, self.addr.port))
|
||||
|
||||
else:
|
||||
utils.print_log("Received unknown command (%02x) from %s:%s..." % (ord(data[2]), self.addr.host, self.addr.port))
|
||||
utils.print_hex(bytearray(data))
|
||||
utils.print_hex(data)
|
||||
|
||||
def find_server(self, query_game, filter, fields, max_servers, game_name, challenge):
|
||||
# Get dictionary from master server list server.
|
||||
print "Searching for server matching '%s' with the fields '%s'" % (filter, fields)
|
||||
|
||||
self.server_list = get_server_list(query_game, filter, fields, max_servers)._getvalue()
|
||||
|
||||
print "Found server(s):"
|
||||
print self.server_list
|
||||
|
||||
if self.server_list == []:
|
||||
self.server_list.append({})
|
||||
|
||||
for _server in self.server_list:
|
||||
server = _server
|
||||
if len(server) > 0 and len(fields) > 0 and server['requested'] == {}:
|
||||
# If the requested fields weren't found then don't return a server.
|
||||
# This fixes a bug with Mario Kart DS.
|
||||
print "Requested was empty"
|
||||
server = {}
|
||||
|
||||
# Generate binary server list data
|
||||
data = generate_server_list_data(self.addr, fields, server)
|
||||
utils.print_hex(data)
|
||||
|
||||
# Encrypt data
|
||||
enc = gs_utils.EncTypeX()
|
||||
data = enc.encrypt(secret_key_list[game_name], challenge, data)
|
||||
|
||||
# Send to client
|
||||
self.transport.write(bytes(data))
|
||||
utils.print_log("Sent server list message to %s:%s..." % (self.addr.host, self.addr.port))
|
||||
|
||||
|
||||
|
||||
class SessionFactory(Factory):
|
||||
def __init__(self):
|
||||
print "Now listening for connections..."
|
||||
|
||||
def buildProtocol(self, addr):
|
||||
return Session(addr)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# Initialize server list server connection
|
||||
class GamespyServerDatabase(BaseManager):
|
||||
pass
|
||||
|
||||
GamespyServerDatabase.register("get_server_list")
|
||||
GamespyServerDatabase.register("modify_server_list")
|
||||
GamespyServerDatabase.register("find_servers")
|
||||
GamespyServerDatabase.register("find_server_by_address")
|
||||
|
||||
manager_address = ("127.0.0.1", 27500)
|
||||
manager_password = ""
|
||||
|
||||
server_manager = GamespyServerDatabase(address = manager_address, authkey= manager_password)
|
||||
server_manager.connect()
|
||||
|
||||
secret_key_list = gs_utils.generate_secret_keys("gslist.cfg")
|
||||
|
||||
endpoint = serverFromString(reactor, "tcp:28910")
|
||||
conn = endpoint.listen(SessionFactory())
|
||||
reactor.run()
|
||||
Reference in New Issue
Block a user