Initial commit

This commit is contained in:
ShadowNinja
2014-06-30 17:01:29 -04:00
commit d4aeaabad2
8 changed files with 649 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
*~
static/list.json
static/servers.js

79
README.md Normal file
View File

@@ -0,0 +1,79 @@
Minetest server list
====================
Setting up the webpage
----------------------
You will have to install node.js, doT.js and their dependencies to compile
the serverlist webpage template.
First install node.js, e.g.:
# apt-get install nodejs
# # OR:
# pacman -S nodejs
# # OR:
# emerge nodejs
Then install doT.js and its dependencies:
$ cd ~
$ npm install dot commander mkdirp
And finally compile the template:
$ cd util/master/static
$ ~/node_modules/dot/bin/dot-packer -s . -d .
You can now serve the webpage by copying the files in static/ to your web root, or by [starting the master server](#setting-up-the-server).
Embedding the server list in a page
-----------------------------------
<head>
...
<script>
var master = {
root: 'http://servers.minetest.net/',
limit: 10,
clients_min: 1,
no_flags: 1,
no_ping: 1,
no_uptime: 1
};
</script>
...
</head>
<body>
...
<div id="server_list"></div>
...
</body>
<script src="list.js"></script>
Setting up the server
---------------------
1. Install Python 3 and pip:
# pacman -S python python-pip
# # OR:
# apt-get install python3 python3-pip
2. Install Flask, APSchedule, and (if using in production) uwsgi:
# # You might have to use pip3 if your system defaults to Python 2
# pip install APSchedule flask uwsgi
3. Configure the server by changing options in config.py, which is a Flask
configuration file.
4. Start the server:
$ ./server.py
$ # Or for production:
$ uwsgi -s /tmp/serverlist.sock -w server:app
$ # Then configure according to http://flask.pocoo.org/docs/deploying/uwsgi/

26
config.py Normal file
View File

@@ -0,0 +1,26 @@
# Enables detailed tracebacks and an interactive Python console on errors.
# Never use in production!
#DEBUG = True
# Makes the server more performant at sending static files when the
# server is being proxied by a server that supports X-Sendfile.
#USE_X_SENDFILE = True
# Address to listen for clients on
HOST = "0.0.0.0"
# Port to listen on
PORT = 8000
# File to store the JSON server list data in.
FILENAME = "list.json"
# Ammount of time, is seconds, after which servers are removed from the list
# if they haven't updated their listings. Note: By default Minetest servers
# only announce once every 5 minutes, so this should be more than 300.
PURGE_TIME = 350
# List of banned IP addresses.
BANLIST = []

319
server.py Executable file
View File

@@ -0,0 +1,319 @@
#!/usr/bin/env python3
import os, sys, json, time, socket
from threading import Thread, RLock
from operator import itemgetter
from apscheduler.scheduler import Scheduler
from flask import Flask, request, send_from_directory
serverList = []
maxServers = 0
maxClients = 0
listLock = RLock()
sched = Scheduler()
sched.start()
app = Flask(__name__, static_url_path = "")
app.config.from_pyfile("config.py")
@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, app.config["FILENAME"],
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["BANLIST"]:
return "Banned.", 403
if request.method == "POST":
data = request.form["json"]
else:
data = request.args["json"]
if len(data) > 5000:
return "JSON data is too big.", 413
try:
server = json.loads(data)
except:
return "Unable to process JSON data.", 400
if not "action" in server:
return "Missing action field.", 400
if server["action"] == "start":
server["uptime"] = 0
if server["action"] != "delete" and not checkRequest(server):
return "Invalid JSON data.", 400
server["ip"] = ip
if not "port" in server:
server["port"] = 30000
old = getServer(server["ip"], server["port"])
if server["action"] == "delete":
if not old:
return "Server not found.", 500
removeServer(old)
saveList()
return "Removed from server list."
if server["action"] != "start" and not old:
# Server to update not found, continue as a new server
server["action"] = "start"
server["update_time"] = time.time()
if server["action"] == "start":
server["start"] = time.time()
else:
server["start"] = old["start"]
if "clients_list" in server:
server["clients"] = len(server["clients_list"])
if old:
server["clients_top"] = max(server["clients"], old["clients_top"])
else:
server["clients_top"] = server["clients"]
# Make sure that startup options don't change
if server["action"] != "start":
if "mods" in old:
server["mods"] = old["mods"]
# Popularity
if old:
server["updates"] = old["updates"] + 1
# This is actally 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 "Thanks, your request has been filed.", 202
# Returns ping time in seconds (up), False (down), or None (error).
def serverUp(address, port):
try:
start = time.time()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.settimeout(3)
buf = b"\x4f\x45\x74\x03\x00\x00\x00\x01"
sock.sendto(buf, (address, port))
data, addr = sock.recvfrom(1000)
if not data:
return False
peer_id = data[12:14]
buf = b"\x4f\x45\x74\x03" + peer_id + b"\x00\x00\x03"
sock.sendto(buf, (address, port))
sock.close()
end = time.time()
return end - start
except socket.timeout:
return False
except:
return None
def getServerAndIndex(ip, port):
with listLock:
for i, server in enumerate(serverList):
if server["ip"] == ip and server["port"] == port:
return (i, server)
def getServer(ip, port):
server = getServerAndIndex(ip, port)
return server and server[1]
def removeServer(server):
with listLock:
try:
serverList.remove(server)
except:
pass
def sortList():
with listLock:
serverList.sort(key=itemgetter("clients", "start"), reverse=True)
@sched.interval_schedule(minutes=1, coalesce=True, max_instances=1)
def purgeOld():
with listLock:
for server in serverList:
if server["update_time"] < time.time() - app.config["PURGE_TIME"]:
serverList.remove(server)
saveList()
def loadList():
global serverList, maxServers, maxClients
try:
with open(os.path.join("static", app.config["FILENAME"]), "r") as fd:
data = json.load(fd)
except FileNotFoundError:
return
if not data:
return
with listLock:
serverList = data["list"]
maxServers = data["total_max"]["servers"]
maxClients = data["total_max"]["clients"]
def saveList():
global maxServers, maxClients
with listLock:
servers = len(serverList)
clients = 0
for server in serverList:
clients += server["clients"]
maxServers = max(servers, maxServers)
maxClients = max(clients, maxClients)
with open(os.path.join("static", app.config["FILENAME"]), "w") as fd:
json.dump({
"total": {"servers": servers, "clients": clients},
"total_max": {"servers": maxServers, "clients": maxClients},
"list": serverList
},
fd,
indent = "\t" if app.config["DEBUG"] else 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"),
"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 anything in boolean fields but convert it to a
# boolean, because old servers send some booleans as strings.
if data[1] == "bool":
server[name] = True if server[name] else False
continue
# clients_max and port were sent as strings instead of integers
if (name == "clients_max" or name == "port") 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):
if "address" in server and server["address"] != "":
try:
info = socket.getaddrinfo(server["address"], server["port"])
except:
app.logger.warning("Unable to get address info for %s." % (server["address"],))
return
addresses = set(data[4][0] for data in info)
found = False
for addr in addresses:
if server["ip"] == addr:
found = True
break
if not found:
app.logger.warning("Invalid IP %s for address %s (address valid for %s)."
% (server["ip"], server["address"], addresses))
return
else:
server["address"] = server["ip"]
server["ping"] = serverUp(server["address"], server["port"])
if not server["ping"]:
return
del server["action"]
with listLock:
old = getServerAndIndex(server["ip"], server["port"])
if old:
serverList[old[0]] = server
else:
serverList.append(server)
sortList()
saveList()
loadList()
purgeOld()
if __name__ == "__main__":
app.run(host = app.config["HOST"], port = app.config["PORT"])

11
static/index.html Normal file
View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Minetest server list</title>
</head>
<body>
<div id="server_list"></div>
</body>
</html>
<script src="list.js"></script>

101
static/list.js Normal file
View File

@@ -0,0 +1,101 @@
var master;
if (!master) master = {};
if (typeof(master.root) == 'undefined') master.root = window.location.href;
if (!master.output) master.output = '#server_list';
if (!master.list) master.list = "list";
if (!master.list_root) master.list_root = master.root;
if (!master.list_url) master.list_url = master.list_root + master.list;
function humanTime(seconds) {
if (!seconds) return '?';
var conv = {
y: 31536000,
d: 86400,
h: 3600,
m: 60
}
for (var i in conv) {
if (seconds >= conv[i]) {
return (seconds / conv[i]).toFixed(1) + i;
}
}
return seconds + 's';
}
function escapeHTML(str) {
if(!str) return str;
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function addressString(server) {
var isIPv6 = server.address.indexOf(":") != -1;
var addrStr = (isIPv6 ? '[' : '') +
escapeHTML(server.address) +
(isIPv6 ? ']' : '');
var shortStr = addrStr;
addrStr += ':' + server.port;
var str = '<span'
if (shortStr.length > 25) {
shortStr = shortStr.substr(0, 23) + "&hellip;";
str += ' class="mts_tooltip" title="' + addrStr + '"'
}
if (server.port != 30000)
shortStr += ':' + server.port;
return str + '>' + shortStr + '</span>';
}
function tooltipString(str, maxLen) {
str = escapeHTML(str);
var shortStr = str;
var ret = '<span';
if (shortStr.length > maxLen) {
shortStr = shortStr.substr(0, maxLen - 2) + "&hellip;";
ret += ' class="mts_tooltip" title="' + str + '"';
}
return ret + '>' + shortStr + '</span>';
}
function hoverList(name, list) {
if (!list || list.length == 0) return '';
var str = '<div class="mts_hover_list">'
str += name + '(' + list.length + ')<br />';
for (var i in list) {
str += escapeHTML(list[i]) + '<br />';
}
return str + '</div>';
}
function hoverString(name, string) {
if (!string) return '';
return '<div class="mts_hover_list">'
+ name + ':<br />'
+ escapeHTML(string) + '<br />'
+ '</div>';
}
function draw(json) {
html = window.render.servers(json);
jQuery(master.output).html(html);
}
function get() {
jQuery.getJSON(master.list_url, draw);
}
function loaded(){
if (!master.no_refresh) {
setInterval(get, 60 * 1000);
}
get();
}
// https://github.com/pyrsmk/toast
this.toast=function(){var e=document,t=e.getElementsByTagName("head")[0],n=this.setTimeout,r="createElement",i="appendChild",s="addEventListener",o="onreadystatechange",u="styleSheet",a=10,f=0,l=function(){--f},c,h=function(e,r,i,s){if(!t)n(function(){h(e)},a);else if(e.length){c=-1;while(i=e[++c]){if((s=typeof i)=="function"){r=function(){return i(),!0};break}if(s=="string")p(i);else if(i.pop){p(i[0]),r=i[1];break}}d(r,Array.prototype.slice.call(e,c+1))}},p=function(n,s){++f,/\.css$/.test(n)?(s=e[r]("link"),s.rel=u,s.href=n,t[i](s),v(s)):(s=e[r]("script"),s.src=n,t[i](s),s[o]===null?s[o]=m:s.onload=l)},d=function(e,t){if(!f)if(!e||e()){h(t);return}n(function(){d(e,t)},a)},v=function(e){if(e.sheet||e[u]){l();return}n(function(){v(e)},a)},m=function(){/ded|co/.test(this.readyState)&&l()};h(arguments)};
toast(master.root + 'style.css', master.root + 'servers.js', function() {
if (typeof(jQuery) != 'undefined')
return loaded();
else
toast('//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js', loaded);
});

74
static/servers.jst Normal file
View File

@@ -0,0 +1,74 @@
{{? !master.no_total}}
<div class="total">
Players: {{=it.total.clients}}/{{=it.total_max.clients}}&nbsp;
Servers: {{=it.total.servers}}/{{=it.total_max.servers}}
</div>
{{?}}
<table>
<tr>
{{? !master.no_address}}<th>IP[:Port]</th>{{?}}
{{? !master.no_clients}}<th>Players/Max{{? !master.no_avgtop}}<br/>Avg/Top{{?}}</th>{{?}}
{{? !master.no_version}}<th>Version, Gameid, MapGen</th>{{?}}
{{? !master.no_name}}<th>Name</th>{{?}}
{{? !master.no_description}}<th>Description</th>{{?}}
{{? !master.no_flags}}<th>Flags</th>{{?}}
{{? !master.no_uptime}}<th>Uptime, Age</th>{{?}}
{{? !master.no_ping}}<th>Ping, Lag</th>{{?}}
</tr>
{{~it.list :server:index}}
{{ if (master.limit && index + 1 > master.limit) break;}}
{{ if (master.min_clients && server.clients < master.min_clients) continue;}}
<tr>
{{? !master.no_address}}
<td class ="address">
{{=addressString(server)}}
</td>{{?}}
{{? !master.no_clients}}
<td class="clients{{? server.clients_list && server.clients_list.length > 0}} mts_hover_list_text{{?}}">
{{=server.clients}}/{{=server.clients_max}}{{? !master.no_avgtop}} &nbsp;&nbsp;{{=Math.floor(server.pop_v)}}/{{=server.clients_top}}{{?}}
{{=hoverList("Clients", server.clients_list)}}
</td>{{?}}
{{? !master.no_version}}
<td class="version{{? server.mods && server.mods.length > 0}} mts_hover_list_text{{?}}">
{{=escapeHTML(server.version)}}, {{=escapeHTML(server.gameid)}},&nbsp;
{{=escapeHTML(server.mapgen || '?')}}
{{=hoverList("Mods", server.mods)}}
</td>{{?}}
{{? !master.no_name}}
<td class="name">
{{? server.url}}
<a href="{{=escapeHTML(server.url)}}">{{=tooltipString(server.name, 25)}}</a>
{{??}}
{{=tooltipString(server.name, 25)}}
{{?}}
</td>{{?}}
{{? !master.no_description}}
<td class="description">
{{=tooltipString(server.description, 50)}}
</td>{{?}}
{{? !master.no_flags}}
<td class="flags {{? server.privs}} mts_hover_list_text{{?}}">
{{=hoverString("Privs", server.privs)}}
{{=server.creative ? 'Cre ' : ''}}
{{=server.dedicated ? 'Ded ' : ''}}
{{=server.damage ? 'Dmg ' : ''}}
{{=server.liquid_finite ? 'Liq ' : ''}}
{{=server.pvp ? 'PvP ' : ''}}
{{=server.password ? 'Pwd ' : ''}}
{{=server.rollback ? 'Rol ' : ''}}
{{=server.can_see_far_names ? 'Far ' : ''}}
</td>{{?}}
{{? !master.no_uptime}}
<td class="uptime">
{{=humanTime(server.uptime)}}, {{=humanTime(server.game_time)}}
</td>{{?}}
{{? !master.no_ping}}
<td class="ping">
{{=Math.floor(server.ping * 1000)}}{{? server.lag}}, {{= Math.floor(server.lag * 1000)}}{{?}}
</td>{{?}}
</tr>
{{~}}
</table>
{{? master.min_clients || master.limit}}
<a class="clickable" onclick="delete master.min_clients; delete master.limit; get();">More...</a>
{{?}}

35
static/style.css Normal file
View File

@@ -0,0 +1,35 @@
#server_list table {
max-width: 100%;
width: 100%;
background-color: transparent;
border-collapse: collapse;
border-spacing: 0;
font-size: small;
}
#server_list td, #server_list th {
border: 1px solid gray;
}
.mts_hover_list{
visibility: hidden;
border: gray solid 1px;
position: absolute;
z-index: 100;
background-color: white;
padding: 0.5em;
}
td:hover .mts_hover_list {
visibility: visible;
}
.mts_hover_list_text, .mts_tooltip {
text-decoration: underline;
text-decoration-style: dashed;
}
.clickable {
text-decoration: underline;
cursor: pointer;
}