As discussed internally there are some edge cases where this would require unreasonable changes on the server owner's part
646 lines
18 KiB
Python
Executable File
646 lines
18 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
import os
|
|
import json
|
|
import time
|
|
import socket
|
|
from threading import Thread, RLock
|
|
from glob import glob
|
|
|
|
import maxminddb
|
|
from flask import Flask, request, send_from_directory, make_response
|
|
|
|
|
|
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")
|
|
|
|
tmp = glob(os.path.join(app.root_path, "dbip-country-lite-*.mmdb"))
|
|
if tmp:
|
|
reader = maxminddb.open_database(tmp[0], maxminddb.MODE_AUTO)
|
|
else:
|
|
app.logger.warning(
|
|
"For working GeoIP download the database from "+
|
|
"https://db-ip.com/db/download/ip-to-country-lite and place the "+
|
|
".mmdb file in the app root folder."
|
|
)
|
|
reader = None
|
|
|
|
# Helpers
|
|
|
|
# checkRequestAddress() error codes
|
|
ADDR_IS_PRIVATE = 1
|
|
ADDR_IS_INVALID = 2
|
|
ADDR_IS_INVALID_PORT = 3
|
|
ADDR_IS_UNICODE = 4
|
|
ADDR_IS_EXAMPLE = 5
|
|
|
|
ADDR_ERROR_HELP_TEXTS = {
|
|
ADDR_IS_PRIVATE: "The server_address you provided is private or local. "
|
|
"It is only reachable in your local network.\n"
|
|
"If you meant to host a public server, adjust the setting and make sure your "
|
|
"firewall is permitting connections (e.g. port forwarding).",
|
|
ADDR_IS_INVALID: "The server_address you provided is invalid.\n"
|
|
"If you don't have a domain name, try removing the setting from your configuration.",
|
|
ADDR_IS_INVALID_PORT: "The server_address you provided is invalid.\n"
|
|
"Note that the value must not include a port number.",
|
|
ADDR_IS_UNICODE: "The server_address you provided includes Unicode characters.\n"
|
|
"For domain names you have to use the punycode notation.",
|
|
ADDR_IS_EXAMPLE: "The server_address you provided is an example value.",
|
|
}
|
|
|
|
def geoip_lookup_continent(ip):
|
|
if ip.startswith("::ffff:"):
|
|
ip = ip[7:]
|
|
|
|
if not reader:
|
|
return
|
|
geo = reader.get(ip)
|
|
|
|
if geo and "continent" in geo:
|
|
return geo["continent"]["code"]
|
|
else:
|
|
app.logger.warning("Unable to get GeoIP continent data for %s.", ip)
|
|
|
|
# Views
|
|
|
|
@app.route("/")
|
|
def index():
|
|
return app.send_static_file("index.html")
|
|
|
|
|
|
@app.route("/list")
|
|
def list_json():
|
|
# We have to make sure that the list isn't cached for too long,
|
|
# since it isn't really static.
|
|
return send_from_directory(app.static_folder, "list.json", max_age=20)
|
|
|
|
|
|
@app.route("/geoip")
|
|
def geoip():
|
|
continent = geoip_lookup_continent(request.remote_addr)
|
|
|
|
resp = make_response({
|
|
"continent": continent, # null on error
|
|
})
|
|
resp.cache_control.max_age = 7 * 86400
|
|
resp.cache_control.private = True
|
|
|
|
return resp
|
|
|
|
|
|
@app.post("/announce")
|
|
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.form["json"]
|
|
|
|
if len(data) > 8192:
|
|
return "JSON data is too big.", 413
|
|
|
|
try:
|
|
server = json.loads(data)
|
|
except json.JSONDecodeError:
|
|
return "Unable to process JSON data.", 400
|
|
|
|
if not isinstance(server, dict):
|
|
return "JSON data is not an object.", 400
|
|
|
|
action = server.get("action")
|
|
if action not in ("start", "update", "delete"):
|
|
return "Invalid action field.", 400
|
|
|
|
server["ip"] = ip
|
|
|
|
if not "port" in server:
|
|
server["port"] = 30000
|
|
#### Compatibility code ####
|
|
# port was sent as a string instead of an integer
|
|
elif isinstance(server["port"], str):
|
|
server["port"] = int(server["port"])
|
|
#### End compatibility 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 checkRequestSchema(server):
|
|
return "JSON data does not conform to schema.", 400
|
|
elif not checkRequest(server):
|
|
return "Incorrect JSON data.", 400
|
|
|
|
if action == "update" and not old:
|
|
if app.config["ALLOW_UPDATE_WITHOUT_OLD"]:
|
|
action = "start"
|
|
else:
|
|
return "Server to update not found."
|
|
|
|
# Since 'address' isn't the primary key it can change
|
|
if action == "start" or old.get("address") != server.get("address"):
|
|
err = checkRequestAddress(server)
|
|
if err:
|
|
return ADDR_ERROR_HELP_TEXTS[err], 400
|
|
|
|
server["update_time"] = int(time.time())
|
|
|
|
if action == "start":
|
|
server["uptime"] = 0
|
|
server["start"] = int(time.time())
|
|
else:
|
|
server["start"] = old["start"]
|
|
|
|
server["clients_top"] = max(server["clients"], old["clients_top"]) if old else server["clients"]
|
|
|
|
# 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"]
|
|
|
|
err = errorTracker.get(getErrorPK(server))
|
|
|
|
finishRequestAsync(server)
|
|
|
|
if err:
|
|
warn, text = err
|
|
if warn:
|
|
text = ("Request has been filed and the previous one was successful, "
|
|
"but take note:\n" + text)
|
|
else:
|
|
text = ("Request has been filed, "
|
|
"but the previous request encountered the following error:\n" + text)
|
|
return text, 409
|
|
return "Request has been filed.", 202
|
|
|
|
# Utilities
|
|
|
|
# returns a primary key suitable for saving and replaying an error unique to a
|
|
# server that was announced.
|
|
def getErrorPK(server):
|
|
# some failures depend on the client IP too
|
|
return(server["ip"], server["address"], server["port"])
|
|
|
|
# check if something is a domain name (approximate)
|
|
def isDomain(s):
|
|
return "." in s and s.rpartition(".")[2][0].isalpha()
|
|
|
|
# Returns ping time in seconds (up), False (down), or None (error).
|
|
def serverUp(info):
|
|
sock = None
|
|
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.monotonic()
|
|
# 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.monotonic()
|
|
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)
|
|
return end - start
|
|
except (socket.timeout, socket.error):
|
|
return False
|
|
except Exception as e:
|
|
app.logger.warning("Unexpected exception during serverUp: %r", e)
|
|
return None
|
|
finally:
|
|
if sock:
|
|
sock.close()
|
|
|
|
|
|
def checkRequestAddress(server):
|
|
name = server["address"].lower()
|
|
|
|
# example value from minetest.conf
|
|
EXAMPLE_TLDS = (".example.com", ".example.net", ".example.org")
|
|
if name == "game.minetest.net" or any(name.endswith(s) for s in EXAMPLE_TLDS):
|
|
return ADDR_IS_EXAMPLE
|
|
|
|
# length limit for good measure
|
|
if len(name) > 255:
|
|
return ADDR_IS_INVALID
|
|
# characters invalid in DNS names and IPs
|
|
if any(c in name for c in " @#/*\"'\t\v\r\n\x00") or name.startswith("-"):
|
|
return ADDR_IS_INVALID
|
|
# if not ipv6, there must be at least one dot (two components)
|
|
# Note: This is not actually true ('com' is valid domain), but we'll assume
|
|
# nobody who owns a TLD will ever host a Luanti server on the root domain.
|
|
# getaddrinfo also allows specifying IPs as integers, we don't want people
|
|
# to do that either.
|
|
if ":" not in name and "." not in name:
|
|
return ADDR_IS_INVALID
|
|
|
|
if app.config["REJECT_PRIVATE_ADDRESSES"]:
|
|
# private IPs (there are more but in practice these are 99% of cases)
|
|
PRIVATE_NETS = ("10.", "192.168.", "127.", "0.")
|
|
if any(name.startswith(s) for s in PRIVATE_NETS):
|
|
return ADDR_IS_PRIVATE
|
|
# reserved TLDs
|
|
RESERVED_TLDS = (".localhost", ".local", ".internal")
|
|
if name == "localhost" or any(name.endswith(s) for s in RESERVED_TLDS):
|
|
return ADDR_IS_PRIVATE
|
|
|
|
# ipv4/domain with port -or- ipv6 bracket notation
|
|
if ("." in name and ":" in name) or (":" in name and "[" in name):
|
|
return ADDR_IS_INVALID_PORT
|
|
|
|
# unicode in hostname
|
|
# Not sure about Python but the Luanti client definitely doesn't support it.
|
|
if any(ord(c) > 127 for c in name):
|
|
return ADDR_IS_UNICODE
|
|
|
|
|
|
# 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": (True, "int"),
|
|
"proto_max": (True, "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"),
|
|
"pvp": (False, "bool"),
|
|
"password": (False, "bool"),
|
|
"rollback": (False, "bool"),
|
|
"can_see_far_names": (False, "bool"),
|
|
}
|
|
|
|
def checkRequestSchema(server):
|
|
for name, data in fields.items():
|
|
if not name in server:
|
|
if data[0]:
|
|
return False
|
|
continue
|
|
#### Compatibility code ####
|
|
if isinstance(server[name], str):
|
|
# Accept strings in boolean fields but convert it to a
|
|
# boolean, because old servers sent some booleans as strings.
|
|
if data[1] == "bool":
|
|
server[name] = server[name].lower() in ("true", "1")
|
|
continue
|
|
# Accept strings in integer fields but convert it to an
|
|
# integer, for interoperability with e.g. core.write_json().
|
|
if data[1] == "int":
|
|
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 checkRequest(server):
|
|
# check numbers
|
|
for field in ("clients", "clients_max", "uptime", "game_time", "lag", "proto_min", "proto_max"):
|
|
if field in server and server[field] < 0:
|
|
return False
|
|
|
|
if server["proto_min"] > server["proto_max"]:
|
|
return False
|
|
|
|
BAD_CHARS = " \t\v\r\n\x00\x27"
|
|
|
|
# URL must be absolute and http(s)
|
|
if "url" in server:
|
|
url = server["url"]
|
|
if not url or not any(url.startswith(p) for p in ["http://", "https://"]) or \
|
|
any(c in url for c in BAD_CHARS):
|
|
del server["url"]
|
|
|
|
# reject funny business in client or mod list
|
|
if "clients_list" in server:
|
|
server["clients"] = len(server["clients_list"])
|
|
for val in server["clients_list"]:
|
|
if not val or any(c in val for c in BAD_CHARS):
|
|
return False
|
|
|
|
if "mods" in server:
|
|
for val in server["mods"]:
|
|
if not val or any(c in val for c in BAD_CHARS):
|
|
return False
|
|
|
|
# sanitize some text
|
|
for field in ("gameid", "mapgen", "version", "privs"):
|
|
if field in server:
|
|
s = server[field]
|
|
for c in BAD_CHARS:
|
|
s = s.replace(c, "")
|
|
server[field] = s
|
|
|
|
# default value
|
|
if "address" not in server or not server["address"]:
|
|
server["address"] = server["ip"]
|
|
|
|
return True
|
|
|
|
|
|
def finishRequestAsync(server):
|
|
th = Thread(name = "ServerListThread",
|
|
target = asyncFinishThread,
|
|
args = (server,))
|
|
th.start()
|
|
|
|
|
|
def asyncFinishThread(server):
|
|
checkAddress = server["ip"] != server["address"]
|
|
errorTracker.remove(getErrorPK(server))
|
|
|
|
try:
|
|
info = socket.getaddrinfo(server["address"],
|
|
server["port"],
|
|
type=socket.SOCK_DGRAM,
|
|
proto=socket.SOL_UDP)
|
|
except socket.gaierror:
|
|
err = "Unable to get address info for %s" % server["address"]
|
|
app.logger.warning(err)
|
|
errorTracker.put(getErrorPK(server), (False, err))
|
|
return
|
|
|
|
if checkAddress:
|
|
addresses = set(data[4][0] for data in info)
|
|
have_v4 = any(d[0] == socket.AF_INET for d in info)
|
|
have_v6 = any(d[0] == socket.AF_INET6 for d in info)
|
|
if server["ip"] in addresses:
|
|
pass
|
|
elif (":" in server["ip"] and not have_v6) or ("." in server["ip"] and not have_v4):
|
|
# If the client is ipv6 and there is no ipv6 on the domain (or the inverse)
|
|
# then the check cannot possibly ever succeed.
|
|
# Because this often happens accidentally just tolerate it.
|
|
pass
|
|
else:
|
|
err = "Requester IP %s does not match host %s" % (server["ip"], server["address"])
|
|
if isDomain(server["address"]):
|
|
err += " (valid: %s)" % " ".join(addresses)
|
|
app.logger.warning(err)
|
|
# handle as warning
|
|
errorTracker.put(getErrorPK(server), (True, err))
|
|
|
|
geo = geoip_lookup_continent(info[-1][4][0])
|
|
if geo:
|
|
server["geo_continent"] = geo
|
|
|
|
server["ping"] = serverUp(info[0])
|
|
if not server["ping"]:
|
|
err = "Server %s port %d did not respond to ping" % (server["address"], server["port"])
|
|
if isDomain(server["address"]):
|
|
err += " (tried %s)" % info[0][4][0]
|
|
app.logger.warning(err)
|
|
errorTracker.put(getErrorPK(server), (False, err))
|
|
return
|
|
|
|
# success!
|
|
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 ValueError:
|
|
pass
|
|
|
|
def sort(self):
|
|
def server_points(server):
|
|
points = 0
|
|
|
|
# 1 per client
|
|
points += server["clients"]
|
|
|
|
# Penalize highly loaded servers to improve player distribution.
|
|
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
|
|
|
|
# reduction to 40% for servers that support both legacy (v4) and v5 clients
|
|
if server["proto_min"] <= 32 and server["proto_max"] > 36:
|
|
points *= 0.4
|
|
|
|
return points
|
|
|
|
with self.lock:
|
|
self.list.sort(key=server_points, reverse=True)
|
|
|
|
def purgeOld(self):
|
|
cutoff = int(time.time()) - app.config["PURGE_TIME"]
|
|
with self.lock:
|
|
count = len(self.list)
|
|
self.list = [server for server in self.list if cutoff <= server["update_time"]]
|
|
if len(self.list) < count:
|
|
self.save()
|
|
|
|
def load(self):
|
|
with self.lock:
|
|
try:
|
|
with open(os.path.join(app.static_folder, "list.json"), "r", encoding="utf-8") 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", encoding="utf-8") 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 ErrorTracker:
|
|
VALIDITY_TIME = 600
|
|
|
|
def __init__(self):
|
|
self.table = {}
|
|
self.lock = RLock()
|
|
|
|
def put(self, k, info):
|
|
with self.lock:
|
|
self.table[k] = (time.monotonic() + ErrorTracker.VALIDITY_TIME, info)
|
|
|
|
def remove(self, k):
|
|
with self.lock:
|
|
self.table.pop(k, None)
|
|
|
|
def get(self, k):
|
|
with self.lock:
|
|
e = self.table.get(k)
|
|
if e and e[0] >= time.monotonic():
|
|
return e[1]
|
|
|
|
def cleanup(self):
|
|
with self.lock:
|
|
now = time.monotonic()
|
|
table = {k: e for k, e in self.table.items() if e[0] >= now}
|
|
self.table = table
|
|
|
|
|
|
class PurgeThread(Thread):
|
|
def __init__(self):
|
|
Thread.__init__(self, daemon=True)
|
|
def run(self):
|
|
while True:
|
|
time.sleep(60)
|
|
serverList.purgeOld()
|
|
errorTracker.cleanup()
|
|
|
|
|
|
# Globals / Startup
|
|
|
|
serverList = ServerList()
|
|
|
|
errorTracker = ErrorTracker()
|
|
|
|
PurgeThread().start()
|
|
|
|
if __name__ == "__main__":
|
|
app.run(host = app.config["HOST"], port = app.config["PORT"])
|