Using geolite2, we attempt to detect the continent of the server and store the 'continent code' in the serverlist. This is reasonably broad enough to help players in the client find truly "nearby" server. The client will have to be changed to select a continent to change ordering or filtering.
448 lines
12 KiB
Python
Executable File
448 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import os, re, sys, json, time, socket
|
|
from threading import Thread, RLock
|
|
from geolite2 import geolite2
|
|
|
|
from flask import Flask, request, send_from_directory
|
|
|
|
|
|
app = Flask(__name__, static_url_path = "")
|
|
|
|
# Load configuration
|
|
app.config.from_pyfile("config-example.py") # Use example for defaults
|
|
if os.path.isfile(os.path.join(app.root_path, "config.py")):
|
|
app.config.from_pyfile("config.py")
|
|
|
|
|
|
# Views
|
|
|
|
@app.route("/")
|
|
def index():
|
|
return app.send_static_file("index.html")
|
|
|
|
|
|
@app.route("/list")
|
|
def list():
|
|
# We have to make sure that the list isn't cached,
|
|
# since the list isn't really static.
|
|
return send_from_directory(app.static_folder, "list.json",
|
|
cache_timeout=0)
|
|
|
|
|
|
@app.route("/announce", methods=["GET", "POST"])
|
|
def announce():
|
|
ip = request.remote_addr
|
|
if ip.startswith("::ffff:"):
|
|
ip = ip[7:]
|
|
|
|
if ip in app.config["BANNED_IPS"]:
|
|
return "Banned (IP).", 403
|
|
|
|
data = request.values["json"]
|
|
|
|
if len(data) > 8192:
|
|
return "JSON data is too big.", 413
|
|
|
|
try:
|
|
server = json.loads(data)
|
|
except:
|
|
return "Unable to process JSON data.", 400
|
|
|
|
if type(server) != dict:
|
|
return "JSON data is not an object.", 400
|
|
|
|
if not "action" in server:
|
|
return "Missing action field.", 400
|
|
|
|
action = server["action"]
|
|
if action not in ("start", "update", "delete"):
|
|
return "Invalid action field.", 400
|
|
|
|
if action == "start":
|
|
server["uptime"] = 0
|
|
|
|
server["ip"] = ip
|
|
|
|
if not "port" in server:
|
|
server["port"] = 30000
|
|
#### Compatability code ####
|
|
# port was sent as a string instead of an integer
|
|
elif type(server["port"]) == str:
|
|
server["port"] = int(server["port"])
|
|
#### End compatability code ####
|
|
|
|
if "%s/%d" % (server["ip"], server["port"]) in app.config["BANNED_SERVERS"]:
|
|
return "Banned (Server).", 403
|
|
elif "address" in server and "%s/%d" % (server["address"].lower(), server["port"]) in app.config["BANNED_SERVERS"]:
|
|
return "Banned (Server).", 403
|
|
elif "address" in server and server["address"].lower() in app.config["BANNED_SERVERS"]:
|
|
return "Banned (Server).", 403
|
|
|
|
old = serverList.get(ip, server["port"])
|
|
|
|
if action == "delete":
|
|
if not old:
|
|
return "Server not found."
|
|
serverList.remove(old)
|
|
serverList.save()
|
|
return "Removed from server list."
|
|
elif not checkRequest(server):
|
|
return "Invalid JSON data.", 400
|
|
|
|
if action == "update" and not old:
|
|
if app.config["ALLOW_UPDATE_WITHOUT_OLD"]:
|
|
old = server
|
|
old["start"] = time.time()
|
|
old["clients_top"] = 0
|
|
old["updates"] = 0
|
|
old["total_clients"] = 0
|
|
else:
|
|
return "Server to update not found."
|
|
|
|
server["update_time"] = time.time()
|
|
|
|
server["start"] = time.time() if action == "start" else old["start"]
|
|
|
|
if "clients_list" in server:
|
|
server["clients"] = len(server["clients_list"])
|
|
|
|
server["clients_top"] = max(server["clients"], old["clients_top"]) if old else server["clients"]
|
|
|
|
if "url" in server:
|
|
url = server["url"]
|
|
if not any(url.startswith(p) for p in ["http://", "https://", "//"]):
|
|
del server["url"]
|
|
|
|
# Make sure that startup options are saved
|
|
if action == "update":
|
|
for field in ("dedicated", "rollback", "mapgen", "privs",
|
|
"can_see_far_names", "mods"):
|
|
if field in old:
|
|
server[field] = old[field]
|
|
|
|
# Popularity
|
|
if old:
|
|
server["updates"] = old["updates"] + 1
|
|
# This is actually a count of all the client numbers we've received,
|
|
# it includes clients that were on in the previous update.
|
|
server["total_clients"] = old["total_clients"] + server["clients"]
|
|
else:
|
|
server["updates"] = 1
|
|
server["total_clients"] = server["clients"]
|
|
server["pop_v"] = server["total_clients"] / server["updates"]
|
|
|
|
finishRequestAsync(server)
|
|
|
|
return "Request has been filed.", 202
|
|
|
|
# Utilities
|
|
|
|
# Returns ping time in seconds (up), False (down), or None (error).
|
|
def serverUp(info):
|
|
try:
|
|
sock = socket.socket(info[0], info[1], info[2])
|
|
sock.settimeout(3)
|
|
sock.connect(info[4])
|
|
# send packet of type ORIGINAL, with no data
|
|
# this should prompt the server to assign us a peer id
|
|
# [0] u32 protocol_id (PROTOCOL_ID)
|
|
# [4] session_t sender_peer_id (PEER_ID_INEXISTENT)
|
|
# [6] u8 channel
|
|
# [7] u8 type (PACKET_TYPE_ORIGINAL)
|
|
buf = b"\x4f\x45\x74\x03\x00\x00\x00\x01"
|
|
sock.send(buf)
|
|
start = time.time()
|
|
# receive reliable packet of type CONTROL, subtype SET_PEER_ID,
|
|
# with our assigned peer id as data
|
|
# [0] u32 protocol_id (PROTOCOL_ID)
|
|
# [4] session_t sender_peer_id
|
|
# [6] u8 channel
|
|
# [7] u8 type (PACKET_TYPE_RELIABLE)
|
|
# [8] u16 seqnum
|
|
# [10] u8 type (PACKET_TYPE_CONTROL)
|
|
# [11] u8 controltype (CONTROLTYPE_SET_PEER_ID)
|
|
# [12] session_t peer_id_new
|
|
data = sock.recv(1024)
|
|
end = time.time()
|
|
if not data:
|
|
return False
|
|
peer_id = data[12:14]
|
|
# send packet of type CONTROL, subtype DISCO,
|
|
# to cleanly close our server connection
|
|
# [0] u32 protocol_id (PROTOCOL_ID)
|
|
# [4] session_t sender_peer_id
|
|
# [6] u8 channel
|
|
# [7] u8 type (PACKET_TYPE_CONTROL)
|
|
# [8] u8 controltype (CONTROLTYPE_DISCO)
|
|
buf = b"\x4f\x45\x74\x03" + peer_id + b"\x00\x00\x03"
|
|
sock.send(buf)
|
|
sock.close()
|
|
return end - start
|
|
except socket.timeout:
|
|
return False
|
|
except:
|
|
return None
|
|
|
|
|
|
# fieldName: (Required, Type, SubType)
|
|
fields = {
|
|
"action": (True, "str"),
|
|
|
|
"address": (False, "str"),
|
|
"port": (False, "int"),
|
|
|
|
"clients": (True, "int"),
|
|
"clients_max": (True, "int"),
|
|
"uptime": (True, "int"),
|
|
"game_time": (True, "int"),
|
|
"lag": (False, "float"),
|
|
|
|
"clients_list": (False, "list", "str"),
|
|
"mods": (False, "list", "str"),
|
|
|
|
"version": (True, "str"),
|
|
"proto_min": (False, "int"),
|
|
"proto_max": (False, "int"),
|
|
|
|
"gameid": (True, "str"),
|
|
"mapgen": (False, "str"),
|
|
"url": (False, "str"),
|
|
"privs": (False, "str"),
|
|
"name": (True, "str"),
|
|
"description": (True, "str"),
|
|
|
|
# Flags
|
|
"creative": (False, "bool"),
|
|
"dedicated": (False, "bool"),
|
|
"damage": (False, "bool"),
|
|
"liquid_finite": (False, "bool"),
|
|
"pvp": (False, "bool"),
|
|
"password": (False, "bool"),
|
|
"rollback": (False, "bool"),
|
|
"can_see_far_names": (False, "bool"),
|
|
}
|
|
|
|
def checkRequest(server):
|
|
for name, data in fields.items():
|
|
if not name in server:
|
|
if data[0]: return False
|
|
else: continue
|
|
#### Compatibility code ####
|
|
# Accept strings in boolean fields but convert it to a
|
|
# boolean, because old servers sent some booleans as strings.
|
|
if data[1] == "bool" and type(server[name]).__name__ == "str":
|
|
server[name] = True if server[name].lower() in ("true", "1") else False
|
|
continue
|
|
# Accept strings in integer fields but convert it to an
|
|
# integer, for interoperability with e.g. minetest.write_json.
|
|
if data[1] == "int" and type(server[name]).__name__ == "str":
|
|
server[name] = int(server[name])
|
|
continue
|
|
#### End compatibility code ####
|
|
if type(server[name]).__name__ != data[1]:
|
|
return False
|
|
if len(data) >= 3:
|
|
for item in server[name]:
|
|
if type(item).__name__ != data[2]:
|
|
return False
|
|
return True
|
|
|
|
|
|
def finishRequestAsync(server):
|
|
th = Thread(name = "ServerListThread",
|
|
target = asyncFinishThread,
|
|
args = (server,))
|
|
th.start()
|
|
|
|
|
|
def asyncFinishThread(server):
|
|
checkAddress = False
|
|
if not "address" in server or not server["address"]:
|
|
server["address"] = server["ip"]
|
|
else:
|
|
checkAddress = True
|
|
|
|
try:
|
|
info = socket.getaddrinfo(server["address"],
|
|
server["port"],
|
|
type=socket.SOCK_DGRAM,
|
|
proto=socket.SOL_UDP)
|
|
except socket.gaierror:
|
|
app.logger.warning("Unable to get address info for %s." % (server["address"],))
|
|
return
|
|
|
|
if checkAddress:
|
|
addresses = set(data[4][0] for data in info)
|
|
if not server["ip"] in addresses:
|
|
app.logger.warning("Invalid IP %s for address %s (address valid for %s)."
|
|
% (server["ip"], server["address"], addresses))
|
|
return
|
|
|
|
reader = geolite2.reader()
|
|
try:
|
|
geo = reader.get(server["ip"])
|
|
except geoip2.errors.GeoIP2Error:
|
|
app.logger.warning("GeoIP lookup failure for %s." % (server["address"],))
|
|
|
|
if "continent" in geo and "code" in geo["continent"]:
|
|
server["geo_continent"] = geo["continent"]["code"]
|
|
else:
|
|
app.logger.warning("Unable to get GeoIP Continent data for %s."
|
|
% (server["address"],))
|
|
|
|
server["ping"] = serverUp(info[0])
|
|
if not server["ping"]:
|
|
app.logger.warning("Server %s:%d has no ping."
|
|
% (server["address"], server["port"]))
|
|
return
|
|
|
|
del server["action"]
|
|
|
|
serverList.update(server)
|
|
|
|
|
|
class ServerList:
|
|
def __init__(self):
|
|
self.list = []
|
|
self.maxServers = 0
|
|
self.maxClients = 0
|
|
self.lock = RLock()
|
|
self.load()
|
|
self.purgeOld()
|
|
|
|
def getWithIndex(self, ip, port):
|
|
with self.lock:
|
|
for i, server in enumerate(self.list):
|
|
if server["ip"] == ip and server["port"] == port:
|
|
return (i, server)
|
|
return (None, None)
|
|
|
|
def get(self, ip, port):
|
|
i, server = self.getWithIndex(ip, port)
|
|
return server
|
|
|
|
def remove(self, server):
|
|
with self.lock:
|
|
try:
|
|
self.list.remove(server)
|
|
except:
|
|
pass
|
|
|
|
def sort(self):
|
|
def server_points(server):
|
|
points = 0
|
|
|
|
# 1 per client, but only 1/8 per "guest" client
|
|
if "clients_list" in server:
|
|
for name in server["clients_list"]:
|
|
if re.match(r"[A-Z][a-z]{3,}[1-9][0-9]{2,3}", name):
|
|
points += 1/8
|
|
else:
|
|
points += 1
|
|
else:
|
|
# Old server (1/4 per client)
|
|
points = server["clients"] / 4
|
|
|
|
# Penalize highly loaded servers to improve player distribution.
|
|
# Note: This doesn't just make more than 80% of max players stop
|
|
# increasing your points, it can actually reduce your points
|
|
# if you have guests.
|
|
cap = int(server["clients_max"] * 0.80)
|
|
if server["clients"] > cap:
|
|
points -= server["clients"] - cap
|
|
|
|
# 1 per month of age, limited to 8
|
|
points += min(8, server["game_time"] / (60*60*24*30))
|
|
|
|
# 1/2 per average client, limited to 4
|
|
points += min(4, server["pop_v"] / 2)
|
|
|
|
# -8 for unrealistic max_clients
|
|
if server["clients_max"] > 200:
|
|
points -= 8
|
|
|
|
# -8 per second of ping over 0.4s
|
|
if server["ping"] > 0.4:
|
|
points -= (server["ping"] - 0.4) * 8
|
|
|
|
# Up to -8 for less than an hour of uptime (penalty linearly decreasing)
|
|
HOUR_SECS = 60 * 60
|
|
uptime = server["uptime"]
|
|
if uptime < HOUR_SECS:
|
|
points -= ((HOUR_SECS - uptime) / HOUR_SECS) * 8
|
|
|
|
return points
|
|
|
|
with self.lock:
|
|
self.list.sort(key=server_points, reverse=True)
|
|
|
|
def purgeOld(self):
|
|
with self.lock:
|
|
self.list = [server for server in self.list if time.time() <= server["update_time"] + app.config["PURGE_TIME"]]
|
|
self.save()
|
|
|
|
def load(self):
|
|
with self.lock:
|
|
try:
|
|
with open(os.path.join(app.static_folder, "list.json"), "r") as fd:
|
|
data = json.load(fd)
|
|
except FileNotFoundError:
|
|
return
|
|
|
|
if not data:
|
|
return
|
|
|
|
self.list = data["list"]
|
|
self.maxServers = data["total_max"]["servers"]
|
|
self.maxClients = data["total_max"]["clients"]
|
|
|
|
def save(self):
|
|
with self.lock:
|
|
servers = len(self.list)
|
|
clients = 0
|
|
for server in self.list:
|
|
clients += server["clients"]
|
|
|
|
self.maxServers = max(servers, self.maxServers)
|
|
self.maxClients = max(clients, self.maxClients)
|
|
|
|
list_path = os.path.join(app.static_folder, "list.json")
|
|
with open(list_path + "~", "w") as fd:
|
|
json.dump({
|
|
"total": {"servers": servers, "clients": clients},
|
|
"total_max": {"servers": self.maxServers, "clients": self.maxClients},
|
|
"list": self.list
|
|
},
|
|
fd,
|
|
indent = "\t" if app.config["DEBUG"] else None,
|
|
separators = (', ', ': ') if app.config["DEBUG"] else (',', ':')
|
|
)
|
|
os.replace(list_path + "~", list_path)
|
|
|
|
def update(self, server):
|
|
with self.lock:
|
|
i, old = self.getWithIndex(server["ip"], server["port"])
|
|
if i is not None:
|
|
self.list[i] = server
|
|
else:
|
|
self.list.append(server)
|
|
|
|
self.sort()
|
|
self.save()
|
|
|
|
class PurgeThread(Thread):
|
|
def __init__(self):
|
|
Thread.__init__(self)
|
|
self.daemon = True
|
|
def run(self):
|
|
while True:
|
|
time.sleep(60)
|
|
serverList.purgeOld()
|
|
|
|
serverList = ServerList()
|
|
|
|
PurgeThread().start()
|
|
|
|
if __name__ == "__main__":
|
|
app.run(host = app.config["HOST"], port = app.config["PORT"])
|