28 Commits

Author SHA1 Message Date
sfan5
fda88af676 Replace outdated URLs 2025-10-16 16:20:11 +02:00
sfan5
2f66e1deca Fix logic error in server duplicate check 2025-10-16 13:45:09 +02:00
sfan5
258add93e0 Fix average client calculation 2025-02-17 19:55:30 +01:00
sfan5
de30a4e1ac Limit updates of list to disk to every 5s 2025-02-17 19:38:27 +01:00
sfan5
b93b50ad11 Move sorting into save operation 2025-02-17 19:38:27 +01:00
sfan5
431ac110c7 Adjust legacy support penalty 2025-02-17 19:38:27 +01:00
sfan5
aedabc50a8 Logically deduplicate servers on list 2025-02-17 19:38:27 +01:00
sfan5
6df3b93f48 Encapsulate server data into class, split persistency 2025-02-17 19:38:27 +01:00
sfan5
f71be0af67 Drop intended verification changes
As discussed internally there are some edge cases where this
would require unreasonable changes on the server owner's part
2025-02-17 19:38:27 +01:00
sfan5
cd0f2a56d0 Add editorconfig 2025-02-17 19:38:27 +01:00
sfan5
4d320bbcf5 Set short max_age for list.json 2024-11-03 12:42:37 +01:00
sfan5
5d191896f3 Some documentation adjustments 2024-11-03 12:38:50 +01:00
sfan5
77951100b9 Apply Luanti rename 2024-11-02 18:25:05 +01:00
sfan5
6edaa91315 Rework domain verification a bit (#67)
Co-authored-by: ShadowNinja <ShadowNinja@users.noreply.github.com>
2024-10-22 22:53:17 +02:00
sfan5
a8a9d92077 Fix some complaints from pylint 2024-10-14 23:54:06 +02:00
sfan5
3d08cd4ff4 Report delayed errors (#66) 2024-10-14 22:39:09 +02:00
sfan5
dcc8d5ec74 Get rid of uptime tracking and point penalty
closes #37
2024-10-05 15:22:54 +02:00
sfan5
df032cb47c Some adjustments to script and template 2024-10-04 11:32:23 +02:00
sfan5
967c1a0b51 Apply modern-normalize CSS 2024-10-04 11:26:27 +02:00
sfan5
4533842e41 Add contact and privacy links 2024-10-04 10:44:55 +02:00
sfan5
1f1af8828c Check request data more carefully 2024-07-08 19:51:55 +02:00
sfan5
4584459fca Don't penalize no clients_list 2024-07-08 18:53:26 +02:00
sfan5
85aff93b02 Fix templating instructions 2024-03-28 16:03:12 +01:00
ROllerozxa
17b52cd647 Subgame -> Game in serverlist frontend 2024-03-28 15:55:04 +01:00
sfan5
78e6c48c85 Drop support for announce via GET 2024-03-06 23:07:08 +01:00
sfan5
cb8fa58df4 Sanity check server addresses against common mistakes 2024-03-03 23:57:09 +01:00
sfan5
c02ed9f07a Fix serverUp error handling 2024-03-03 23:57:09 +01:00
sfan5
d945b26f9f Add minimal lint workflow 2024-03-02 18:55:10 +01:00
29 changed files with 1002 additions and 1351 deletions

View File

@@ -1,6 +1,7 @@
root = true
[*]
charset = utf-8
indent_style = tab
indent_size = 4
trim_trailing_whitespace = true

28
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: lint
on:
push:
paths:
- '**.py'
- 'requirements.txt'
pull_request:
paths:
- '**.py'
- 'requirements.txt'
jobs:
pylint:
runs-on: ubuntu-latest
container:
image: python:3.11-slim
steps:
- uses: actions/checkout@v3
- name: Install deps
run: |
pip install -r requirements.txt
pip install pylint
- name: Lint
run: |
pylint -E --fail-on=E server.py

15
.gitignore vendored
View File

@@ -1,11 +1,8 @@
*~
*.mmdb
*.sqlite
node_modules/
__pycache__/
/server_list/static/list.json
/server_list/static/servers.js
node_modules
__pycache__
/store.json
/static/list.json
/static/servers.js
/config.py
/celerybeat-schedule
/package-lock.json
/Pipfile.lock
/*.mmdb

18
Pipfile
View File

@@ -1,18 +0,0 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
maxminddb = ">=2.0.0"
Flask = "~=2.0"
flask-sqlalchemy = "~=3.0"
flask-migrate = "~=4.0"
celery = "~=5.0"
[dev-packages]
pylint = "*"
rope = "*"
[requires]
python_version = "3"

207
README.md
View File

@@ -1,48 +1,50 @@
Minetest Server List
Luanti server list
====================
Webpage Setup
---
Setting up the webpage
----------------------
You will have to install node.js, doT.js and their dependencies to compile the server list webpage template.
You will have to install node.js, doT.js and their dependencies to compile
the server list webpage template.
First install node.js, e.g.:
```sh
sudo pacman -S nodejs
apt-get install nodejs
# OR:
sudo apt-get install nodejs
yum install nodejs
```
Then install doT.js and its dependencies:
```sh
npm install
npm install dot "commander@11.1.0" mkdirp
```
And finally compile the template:
```sh
cd server_list/static
../../node_modules/dot/bin/dot-packer -s .
cd static
../node_modules/dot/bin/dot-packer -s .
```
You can now serve the webpage by copying the files in `server_list/static/` to your web root, or by [starting the server list](#server-setup).
You can now serve the webpage by copying the files in `static/` to your web root, or by [starting the server list](#setting-up-the-server).
Embedding in a Webpage
---
Embedding the server list in a page
-----------------------------------
```html
<head>
...
<script>
var master = {
root: 'https://servers.minetest.net/',
root: 'https://servers.luanti.org/',
limit: 10,
clients_min: 1,
no_flags: 1,
no_ping: 1,
no_uptime: 1
no_flags: true,
no_ping: true,
no_uptime: true
};
</script>
...
@@ -51,154 +53,97 @@ Embedding in a Webpage
...
<div id="server_list"></div>
...
<script defer src="https://servers.luanti.org/list.js"></script>
</body>
<script src="list.js"></script>
```
Server Setup
---
Setting up the server
---------------------
1. Install Python 3 and Pipenv:
1. Install Python 3 and pip:
```sh
sudo pacman -S python python-pipenv
# OR:
sudo apt-get install python3 python3-pip && pip install pipenv
```
```sh
apt-get install python3 python3-pip
# OR:
yum install python3 python3-pip
```
2. Install required Python packages:
2. Install required Python packages:
```sh
pipenv sync
```
pip3 install -r requirements.txt
3. Set up Celery message broker. Pick a Celery backend (Redis or RabbitMQ are recommended), and install and enable the required packages. For example:
3. If using in production, install uwsgi and its python plugin:
```sh
# Redis support requires an additional package
pipenv run pip install redis
sudo pacman -S redis # or sudo apt-get install redis
sudo systemctl enable --now redis
```
```sh
apt-get install uwsgi-plugin-python3
# OR:
yum install uwsgi uwsgi-plugin-python3
```
4. Configure the server by adding options to `config.py`.
See `server_list/config.py` for defaults.
4. Configure the server by adding options to `config.py`.
See `config-example.py` for defaults.
5. Start the server for development:
5. Start the server:
```sh
pipenv run flask run
```
```sh
./server.py
# Or for production:
uwsgi -s /run/serverlist.sock --plugins python3 -w server:app -T --threads 2
# then configure according to https://flask.palletsprojects.com/en/stable/deploying/uwsgi/
```
6. Start the celery background worker:
7. (optional) Configure the proxy server, if any. You should make the server
load static files directly from the static directory. Also, `/list`
should be served from `list.json`. Example for nginx:
```sh
pipenv run celery --app server_list:celery worker --beat
```
```sh
root /path/to/server/static;
Running in Production
---
rewrite ^/$ /index.html break;
rewrite ^/list$ /list.json break;
When running in production you should set up a proxy server that calls the server list through WSGI.
location = /list.json { expires 20s; }
These examples assume that the server list is installed to `/srv/http/serverlist`.
### Nginx
First [set up uWSGI](#uwsgi), then update the Nginx configuration to proxy to uWSGI. You should make the server load static files directly from the static directory. Also, `/list` should be aliased to `list.json`.
Here's an example configuration:
```nginx
root /srv/http/serverlist/server_list/static;
rewrite ^/list$ /list.json;
try_files $uri @uwsgi;
location @uwsgi {
uwsgi_pass unix:/run/uwsgi/server_list.sock;
include uwsgi_params;
uwsgi_pass unix:/run/serverlist.sock;
}
```
Also see [the Flask uwsgi documentation](https://flask.palletsprojects.com/en/2.0.x/deploying/uwsgi/).
Setting up the server (Apache version)
--------------------------------------
### Apache
If you wish to use Apache to host the server list, do steps 1-2, 4, above.
Additionally install/enable mod_wsgi and an Apache site config like the following:
There are two options for Apache, you can use either `mod_wsgi` or `mod_proxy_uwsgi`.
```sh
# This config assumes you have the server list at DocumentRoot.
# Visitors to the server list in this config would visit http://local.server/ and
# apache would serve up the output from server.py.
Note: both of these example configurations serve static through WSGI, instead of bypassing WSGI for performance.
# Where are the serverlist files located?
DocumentRoot /var/games/luanti/serverlist
#### mod_wsgi
# Serve up server.py at the root of the URL.
WSGIScriptAlias / /var/games/luanti/serverlist/server.py
First install/enable `mod_wsgi`.
Then create `wsgi.py` in the directory containing `server_list` with the following contents:
```py
import os, sys
sys.path.append(os.path.dirname(__file__))
from server_list import app
```
Then configure the Apache VirtualHost like the following:
```apache
WSGIDaemonProcess server_list python-home=<output of pipenv --venv>
WSGIProcessGroup server_list
WSGIApplicationGroup %{GLOBAL}
WSGIScriptAlias / /srv/http/serverlist/wsgi.py
# The name of the function that we call when we invoke server.py
WSGICallableObject app
<Directory /srv/http/serverlist>
<Files wsgi.py>
Require all granted
</Files>
# These options are necessary to enable Daemon mode. Without this, you'll have strange behavior
# with servers dropping off your list! You can tweak threads as needed. See mod_wsgi documentation.
WSGIProcessGroup luanti-serverlist
WSGIDaemonProcess luanti-serverlist threads=2
<Directory /var/games/luanti/serverlist>
Require all granted
</Directory>
```
#### mod_proxy_uwsgi
First [set up uWSGI](#uwsgi), then install/enable `mod_proxy` and `mod_proxy_uwsgi` and add the following to your VirtualHost:
```apache
ProxyPass / unix:/run/uwsgi/server_list.sock|uwsgi://localhost/
```
Note: this requires at least Apache 2.4.7 for the unix socket syntax. If you have an older version of Apache you'll have to use IP sockets.
### uWSGI
First, install uWSGI and its python plugin.
```sh
pacman -S uwsgi uwsgi-plugin-python
# OR:
apt-get install uwsgi uwsgi-plugin-python
# OR:
pip install uwsgi
```
Then create a uWSGI config file. For example:
```ini
[uwsgi]
socket = /run/uwsgi/server_list.sock
plugin = python
virtualenv = <output of pipenv --venv>
python-path = /srv/http/serverlist
module = server_list
callable = app
```
You can put the config file in `/etc/uwsgi/server_list.ini`. Make sure that uWSGI is configured to start as the appropriate user and group for your distro (e.g. http:http) and then start and enable uWSGI.
```sh
systemctl enable --now uwsgi@server_list.service
```
License
---
-------
The Minetest server list code is licensed under the GNU Lesser General Public
The Luanti server list code is licensed under the GNU Lesser General Public
License version 2.1 or later (LGPLv2.1+). A LICENSE.txt file should have been
supplied with your copy of this software containing a copy of the license.

30
config-example.py Normal file
View File

@@ -0,0 +1,30 @@
# Enables detailed tracebacks and an interactive Python console on errors.
# Never use in production!
DEBUG = False
# Address for development server to listen on
HOST = "127.0.0.1"
# Port for development server to listen on
PORT = 5000
# Amount of time, is seconds, after which servers are removed from the list
# if they haven't updated their listings. Note: By default Luanti servers
# only announce once every 5 minutes, so this should be more than 300.
PURGE_TIME = 350
# List of banned IP addresses for announce
# e.g. ['2620:101::44']
BANNED_IPS = []
# List of banned servers as host/port pairs
# e.g. ['1.2.3.4/30000', 'lowercase.hostname', 'lowercase.hostname/30001']
BANNED_SERVERS = []
# Creates server entries if a server sends an 'update' and there is no entry yet.
# This should only be used to populate the server list after list.json was deleted.
# This WILL cause problems such as mapgen, mods and privilege information missing from the list
ALLOW_UPDATE_WITHOUT_OLD = False
# Reject servers with private addresses and domain names.
# Enable this if you are running a list on the public internet.
REJECT_PRIVATE_ADDRESSES = False

View File

@@ -1,40 +0,0 @@
[alembic]
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@@ -1,76 +0,0 @@
from __future__ import with_statement
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
config = context.config
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
config.set_main_option(
'sqlalchemy.url',
str(current_app.extensions['migrate'].db.get_engine().url).replace('%', '%%'))
target_metadata = current_app.extensions['migrate'].db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=target_metadata, literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
connectable = current_app.extensions['migrate'].db.get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
**current_app.extensions['migrate'].configure_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -1,22 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date.date()}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -1,73 +0,0 @@
"""Initial migration
Revision ID: 00ac5d537063
Create Date: 2021-06-12
"""
from alembic import op
import sqlalchemy as sa
revision = '00ac5d537063'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
op.create_table('server',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('world_uuid', sa.String(length=36), nullable=True),
sa.Column('online', sa.Boolean(), nullable=False),
sa.Column('address', sa.String(), nullable=False),
sa.Column('port', sa.Integer(), nullable=False),
sa.Column('announce_ip', sa.String(), nullable=False),
sa.Column('server_id', sa.String(), nullable=True),
sa.Column('clients', sa.String(), nullable=True),
sa.Column('clients_top', sa.Integer(), nullable=False),
sa.Column('clients_max', sa.Integer(), nullable=False),
sa.Column('first_seen', sa.DateTime(), nullable=False),
sa.Column('start_time', sa.DateTime(), nullable=False),
sa.Column('last_update', sa.DateTime(), nullable=False),
sa.Column('total_uptime', sa.Float(), nullable=False),
sa.Column('down_time', sa.DateTime(), nullable=True),
sa.Column('game_time', sa.Integer(), nullable=False),
sa.Column('lag', sa.Float(), nullable=True),
sa.Column('ping', sa.Float(), nullable=False),
sa.Column('mods', sa.String(), nullable=True),
sa.Column('version', sa.String(), nullable=False),
sa.Column('proto_min', sa.Integer(), nullable=False),
sa.Column('proto_max', sa.Integer(), nullable=False),
sa.Column('game_id', sa.String(), nullable=False),
sa.Column('mapgen', sa.String(), nullable=True),
sa.Column('url', sa.String(), nullable=True),
sa.Column('default_privs', sa.String(), nullable=True),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=False),
sa.Column('popularity', sa.Float(), nullable=False),
sa.Column('geo_continent', sa.String(length=2), nullable=True),
sa.Column('creative', sa.Boolean(), nullable=False),
sa.Column('is_dedicated', sa.Boolean(), nullable=False),
sa.Column('damage_enabled', sa.Boolean(), nullable=False),
sa.Column('pvp_enabled', sa.Boolean(), nullable=False),
sa.Column('password_required', sa.Boolean(), nullable=False),
sa.Column('rollback_enabled', sa.Boolean(), nullable=False),
sa.Column('can_see_far_names', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_server_address_port', 'server', ['address', 'port'], unique=True)
op.create_index(op.f('ix_server_online'), 'server', ['online'], unique=False)
op.create_index(op.f('ix_server_world_uuid'), 'server', ['world_uuid'], unique=True)
op.create_table('stats',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('max_servers', sa.Integer(), nullable=False),
sa.Column('max_clients', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
def downgrade():
op.drop_table('stats')
op.drop_index(op.f('ix_server_world_uuid'), table_name='server')
op.drop_index(op.f('ix_server_online'), table_name='server')
op.drop_index('ix_server_address_port', table_name='server')
op.drop_table('server')

View File

@@ -1,23 +0,0 @@
"""Add address verification required field
Revision ID: d6af394ec1ab
Revises: 00ac5d537063
Create Date: 2021-07-10
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql.expression import text
revision = 'd6af394ec1ab'
down_revision = '00ac5d537063'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('server', sa.Column('address_verification_required', sa.Boolean(), nullable=False, server_default=text('0')))
def downgrade():
op.drop_column('server', 'address_verification_required')

View File

@@ -1,6 +0,0 @@
{
"dependencies": {
"commander": "^7.2.0",
"dot": "^1.1.3"
}
}

View File

@@ -1,5 +1,2 @@
maxminddb-geolite2>=2018.703
Flask~=2.0
flask-sqlalchemy~=2.0
flask-migrate~=3.0
celery~=5.0
Flask>=2.0.0
maxminddb>=2.0.0

777
server.py Executable file
View File

@@ -0,0 +1,777 @@
#!/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
LIST_SAVE_INTERVAL = 5
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=LIST_SAVE_INTERVAL)
@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
json_data = request.form["json"]
if len(json_data) > 8192:
return "JSON data is too big.", 413
try:
req = json.loads(json_data)
except json.JSONDecodeError:
return "Unable to process JSON data.", 400
if not isinstance(req, dict):
return "JSON data is not an object.", 400
action = req.pop("action", "")
if action not in ("start", "update", "delete"):
return "Invalid action field.", 400
req["ip"] = ip
if not "port" in req:
req["port"] = 30000
#### Compatibility code ####
# port was sent as a string instead of an integer
elif isinstance(req["port"], str):
req["port"] = int(req["port"])
#### End compatibility code ####
if "%s/%d" % (req["ip"], req["port"]) in app.config["BANNED_SERVERS"]:
return "Banned (Server).", 403
elif "address" in req and "%s/%d" % (req["address"].lower(), req["port"]) in app.config["BANNED_SERVERS"]:
return "Banned (Server).", 403
elif "address" in req and req["address"].lower() in app.config["BANNED_SERVERS"]:
return "Banned (Server).", 403
old = serverList.get(ip, req["port"])
if action == "delete":
if not old:
return "Server not found."
serverList.remove(old)
return "Removed from server list."
if not checkRequestSchema(req):
return "JSON data does not conform to schema.", 400
elif not checkRequest(req):
return "Incorrect JSON data.", 400
if action == "update" and not old:
if app.config["ALLOW_UPDATE_WITHOUT_OLD"] and req["uptime"] > 0:
action = "start"
else:
return "Server to update not found."
server = Server.from_request(req)
# Since 'address' isn't the primary key it can change
if action == "start" or old.address != server.address:
err = checkRequestAddress(server)
if err:
return ADDR_ERROR_HELP_TEXTS[err], 400
server.track_update(old, action == "update")
err = errorTracker.get(server.get_error_pk())
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
# check if something is a domain name (approximate)
def isDomain(s):
return "." in s and s.rpartition(".")[2][0].isalpha()
# Safely writes JSON data to a file
def dumpJsonToFile(filename, data):
with open(filename + "~", "w", encoding="utf-8") as fd:
json.dump(data, fd,
indent = "\t" if app.config["DEBUG"] else None,
separators = (',', ': ') if app.config["DEBUG"] else (',', ':')
)
os.replace(filename + "~", filename)
# 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 = {
"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(req):
for name, data in fields.items():
if not name in req:
if data[0]:
return False
continue
#### Compatibility code ####
if isinstance(req[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":
req[name] = req[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":
req[name] = int(req[name])
continue
#### End compatibility code ####
if type(req[name]).__name__ != data[1]:
return False
if len(data) >= 3:
for item in req[name]:
if type(item).__name__ != data[2]:
return False
return True
def checkRequest(req):
# check numbers
for field in ("clients", "clients_max", "uptime", "game_time", "lag", "proto_min", "proto_max"):
if field in req and req[field] < 0:
return False
if req["proto_min"] > req["proto_max"]:
return False
BAD_CHARS = " \t\v\r\n\x00\x27"
# URL must be absolute and http(s)
if "url" in req:
url = req["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 req["url"]
# reject funny business in client or mod list
if "clients_list" in req:
req["clients"] = len(req["clients_list"])
for val in req["clients_list"]:
if not val or any(c in val for c in BAD_CHARS):
return False
if "mods" in req:
for val in req["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 req:
s = req[field]
for c in BAD_CHARS:
s = s.replace(c, "")
req[field] = s
# default value
if "address" not in req or not req["address"]:
req["address"] = req["ip"]
return True
def finishRequestAsync(server):
th = Thread(name = "ServerListThread",
target = asyncFinishThread,
args = (server,))
th.start()
def asyncFinishThread(server: 'Server'):
errorTracker.remove(server.get_error_pk())
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(server.get_error_pk(), (False, err))
return
if server.ip == server.address:
server.verifyLevel = 3
else:
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:
server.verifyLevel = 3
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.
server.verifyLevel = 2
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(server.get_error_pk(), (True, err))
server.verifyLevel = 1
# do this here since verifyLevel is now set
if serverList.checkDuplicate(server):
err = "Server %s port %d already exists on the list" % (server.address, server.port)
app.logger.warning(err)
errorTracker.put(server.get_error_pk(), (False, err))
return
geo = geoip_lookup_continent(info[-1][4][0])
if geo:
server.meta["geo_continent"] = geo
ping = serverUp(info[0])
if not 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(server.get_error_pk(), (False, err))
return
server.meta["ping"] = round(ping, 5)
# success!
serverList.update(server)
# represents a single server on the list
class Server:
PROPS = ("startTime", "updateCount", "updateTime", "totalClients", "verifyLevel")
def __init__(self, ip: str, port: str, address: str, meta: dict):
# IP of announce requester (PRIMARY KEY)
self.ip = ip
# port of server (PRIMARY KEY)
self.port = port
# connectable domain or IP
self.address = address
# public metadata
self.meta = meta
# unix time of first update
self.startTime = 0
# number of updates received
self.updateCount = 0
# unix time of last update received
self.updateTime = 0
# total clients counted over all updates
self.totalClients = 0
# how well the IP verification was passed (bigger is better)
self.verifyLevel = 0
# creates an instance from the data of an announce request
@classmethod
def from_request(cls, data):
ip = data["ip"]
port = data["port"]
address = data["address"]
for k in ("ip", "port", "address", "action"):
data.pop(k, 0)
return cls(ip, port, address, data)
# creates an instance from persisted data
@classmethod
def from_storage(cls, data):
p = data.pop()
obj = cls(*data)
for k in cls.PROPS:
if k in p:
setattr(obj, k, p[k])
return obj
# returns data to persist
def to_storage(self):
p = {k: getattr(self, k) for k in self.PROPS}
return (self.ip, self.port, self.address, self.meta, p)
# returns server dict intended for public list
def to_list_json(self):
ret = self.meta.copy()
ret["address"] = self.address
ret["port"] = self.port
ret["pop_v"] = self.get_average_clients()
return ret
# returns a primary key suitable for saving and replaying an error unique to a
# server that was announced.
def get_error_pk(self):
# some failures depend on the client IP too
return (self.ip, self.port, self.address)
def get_average_clients(self):
if self.updateCount:
# FIXME: this is quite biased to servers that are new (or restart often)
return round(self.totalClients / self.updateCount)
return 0
# calculate score for server list ranking (higher values first)
def get_score(self):
points = 0
meta = self.meta
# 1 per client
points += meta["clients"]
# Penalize highly loaded servers to improve player distribution
cap = int(meta["clients_max"] * 0.80)
if meta["clients"] > cap:
points -= meta["clients"] - cap
# 1 per month of age, limited to 8
points += min(8, meta["game_time"] / (60*60*24*30))
# 1/2 per average client, limited to 4
points += min(4, self.get_average_clients() / 2)
# -8 for unrealistic max_clients
if meta["clients_max"] > 200:
points -= 8
# -8 per second of ping over 0.4s
if meta["ping"] > 0.4:
points -= (meta["ping"] - 0.4) * 8
# reduction to 60% for servers that support both legacy (v4) and v5 clients
if meta["proto_min"] <= 32 and meta["proto_max"] > 36:
points *= 0.6
return points
def track_update(self, old: 'Server', is_update: bool):
# this might look a bit strange, but once a `Server` object is put into
# list it becomes immutable since it's under lock. So we have to copy
# stuff we need from the old object, and then replace it later.
assert old or not is_update
self.startTime = old.startTime if old else int(time.time())
self.updateTime = int(time.time())
# Make sure that startup options are saved
if is_update:
for field in ("dedicated", "rollback", "mapgen", "privs",
"can_see_far_names", "mods"):
if field in old.meta:
self.meta[field] = old.meta[field]
else:
self.meta["uptime"] = 0
# Popularity
if old:
self.updateCount = old.updateCount + 1
self.totalClients = old.totalClients + self.meta["clients"]
self.meta["clients_top"] = max(self.meta["clients"], old.meta["clients_top"])
else:
self.updateCount = 1
self.meta["clients_top"] = self.totalClients = self.meta["clients"]
# check if *this* server is a logical duplicate of the other one
def is_duplicate(self, other: 'Server'):
if self.port == other.port and self.address.lower() == other.address.lower():
# if everything matches it's not a duplicate but literally the same
if self.ip == other.ip:
return False
# we have a duplicate but the more trusted one gets to stay
return self.verifyLevel < other.verifyLevel
return False
# Represents an ordered list of server instances as well as methods
# to persist it.
class ServerList:
def __init__(self):
self.list = []
self.maxServers = 0
self.maxClients = 0
self.storagePath = os.path.join(app.root_path, "store.json")
self.publicPath = os.path.join(app.static_folder, "list.json")
self.modified = True
self.lock = RLock()
self.load()
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
# returns true if the given server shouldn't be on the list
def checkDuplicate(self, other_server: Server):
with self.lock:
for server in self.list:
if other_server.is_duplicate(server):
return True
return False
def remove(self, server):
with self.lock:
try:
self.list.remove(server)
except ValueError:
return
self.modified = 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.updateTime]
if len(self.list) < count:
self.modified = True
def load(self):
# TODO?: this is a poor man's database. maybe we should use sqlite3?
with self.lock:
try:
with open(self.storagePath, "r", encoding="utf-8") as fd:
data = json.load(fd)
except FileNotFoundError:
return
if not data:
return
self.list = list(Server.from_storage(x) for x in data["list"])
self.maxServers = data["maxServers"]
self.maxClients = data["maxClients"]
# rewrite once in case writing format or so changed
self.modified = True
def save(self):
with self.lock:
self._saveStorage()
self._savePublic()
self.modified = False
def _saveStorage(self):
with self.lock:
if not self.modified:
return
out_list = list(server.to_storage() for server in self.list)
dumpJsonToFile(self.storagePath, {
"list": out_list,
"maxServers": self.maxServers,
"maxClients": self.maxClients
})
def _savePublic(self):
with self.lock:
if not self.modified and os.path.exists(self.publicPath):
return
# sort, but don't modify internal list
sorted_list = sorted(self.list,
key=(lambda server: server.get_score()), reverse=True)
out_list = []
servers = len(sorted_list)
clients = 0
for server in sorted_list:
out_list.append(server.to_list_json())
clients += server.meta["clients"]
self.maxServers = max(servers, self.maxServers)
self.maxClients = max(clients, self.maxClients)
dumpJsonToFile(self.publicPath, {
"total": {"servers": servers, "clients": clients},
"total_max": {"servers": self.maxServers, "clients": self.maxClients},
"list": out_list
})
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.modified = True
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 TimerThread(Thread):
def __init__(self):
super().__init__(name="TimerThread", daemon=True)
def run(self):
next_cleanup = 0
while True:
time.sleep(max(1, LIST_SAVE_INTERVAL))
if time.monotonic() >= next_cleanup:
serverList.purgeOld()
errorTracker.cleanup()
next_cleanup = time.monotonic() + 60
serverList.save()
# Globals / Startup
serverList = ServerList()
errorTracker = ErrorTracker()
TimerThread().start()
if __name__ == "__main__":
app.run(host = app.config["HOST"], port = app.config["PORT"])

View File

@@ -1,2 +0,0 @@
from .app import app, celery
from . import commands, tasks, views

View File

@@ -1,35 +0,0 @@
import os
from celery import Celery
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
app = Flask(__name__, static_url_path="")
# Load defaults
app.config.from_pyfile("config.py")
# Load configuration
if os.path.isfile(os.path.join(app.root_path, "..", "config.py")):
app.config.from_pyfile("../config.py")
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
db = SQLAlchemy(app)
migrate = Migrate(app, db)
celery = Celery(
app.import_name,
broker=app.config['CELERY_BROKER_URL']
)
celery.conf.update(app.config)
class ContextTask(celery.Task):
def __call__(self, *args, **kwargs):
with app.app_context():
return self.run(*args, **kwargs)
celery.Task = ContextTask

View File

@@ -1,35 +0,0 @@
import json
import click
from .app import app, db
from .models import Server, Stats
@app.cli.command("load-json")
@click.argument("filename")
@click.option("--update")
def load_json(filename, update):
"""Load the SQL database with servers from a JSON server list.
"""
with open(filename, "r") as fd:
data = json.load(fd)
assert data
for obj in data["list"]:
if update:
obj.setdefault("address", obj["ip"])
Server.create_or_update(obj)
else:
server = Server()
server.update(obj, True)
db.session.add(server)
stats = Stats.get()
stats.max_servers = data["total_max"]["servers"]
stats.max_clients = data["total_max"]["clients"]
db.session.add(stats)
db.session.commit()
click.echo(click.style(f'Loaded {len(data["list"])} servers', fg="green"))

View File

@@ -1,45 +0,0 @@
from datetime import timedelta
from glob import glob
# Enables detailed tracebacks and an interactive Python console on errors.
# Never use in production!
DEBUG = False
# Amount of time, in 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 = timedelta(minutes=6)
# List of banned IP addresses for announce
# e.g. ['2620:101::44']
BANNED_IPS = []
# List of banned servers as host/port pairs
# e.g. ['1.2.3.4/30000', 'lowercase.hostname', 'lowercase.hostname/30001']
BANNED_SERVERS = []
# Creates server entries if a server sends an 'update' and there is no entry yet.
# This should only be used to populate the server list after list.json was deleted.
# This WILL cause problems such as mapgen, mods and privilege information missing from the list
ALLOW_UPDATE_WITHOUT_OLD = False
# Database to use to store persistent server information
SQLALCHEMY_DATABASE_URI = "sqlite:///server_list.sqlite"
# How strongly past player counts are weighted into the popularity
# over the current player count.
POPULARITY_FACTOR = 0.9
# Message broker to forward messages from web server to worker threads
# Redis and RabbitMQ are good options.
#CELERY_BROKER_URL = "redis://localhost/0"
# Maximum number of clients before a server will be considered heavily loaded
# and down-weighted to improve player distribution.
CLIENT_LIMIT = 32
# MaxMind GeoIP database.
# You can download a copy from https://db-ip.com/db/download/ip-to-country-lite
mmdbs = glob("dbip-country-lite-*.mmdb")
if mmdbs:
MAXMIND_DB = mmdbs[0]

View File

@@ -1,270 +0,0 @@
from datetime import datetime
from sqlalchemy.orm.exc import NoResultFound
from .app import app, db
class Server(db.Model):
__table_args__ = (db.Index("ix_server_address_port", "address", "port", unique=True),)
id = db.Column(db.Integer, primary_key=True)
# World-specific UUID used to identify the server.
# This is kept secret to prevent anyone from spoofing the server.
world_uuid = db.Column(db.String(36), nullable=True, index=True, unique=True)
# Whether the server is currently online
online = db.Column(db.Boolean, index=True, nullable=False, default=True)
# Server sent connection address
address = db.Column(db.String, nullable=False)
port = db.Column(db.Integer, nullable=False, default=30000)
# IP address announcement was received from
announce_ip = db.Column(db.String, nullable=False)
# Name of server software. E.g. "minetest"
server_id = db.Column(db.String, nullable=True)
# List of player names, one per line
clients = db.Column(db.String, nullable=True)
# Highest number of clients ever seen
clients_top = db.Column(db.Integer, nullable=False)
# Maximum number of allowed clients
clients_max = db.Column(db.Integer, nullable=False)
# First time that we received an announcement from this server
first_seen = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
# Time that server sent "start" announcement.
# This can be used to calculate the current uptime.
start_time = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
# Time of most recent update request
last_update = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
# Amount of time that we've seen the server up for, in seconds
total_uptime = db.Column(db.Float, nullable=False)
# Most recent time that the server went down
down_time = db.Column(db.DateTime, nullable=True)
# Server sent value for age of world.
# Should nearly match uptime on a server that always announces.
game_time = db.Column(db.Integer, nullable=False)
# Server sent value based on sever loop timing
lag = db.Column(db.Float, nullable=True)
# Ping time in seconds
ping = db.Column(db.Float, nullable=False)
# List of enabled mods, one per line
mods = db.Column(db.String, nullable=True)
# Server release version
version = db.Column(db.String, nullable=False)
# Supported protocol versions
proto_min = db.Column(db.Integer, nullable=False)
proto_max = db.Column(db.Integer, nullable=False)
game_id = db.Column(db.String, nullable=False)
# Mapgen name
mapgen = db.Column(db.String, nullable=True)
# Server landing page URL
url = db.Column(db.String, nullable=True)
# Privileges granted to new players by default
default_privs = db.Column(db.String, nullable=True)
name = db.Column(db.String, nullable=False)
description = db.Column(db.String, nullable=False)
# Roughly the average number of players on the server
popularity = db.Column(db.Float, nullable=False)
# Continent determined from IP
geo_continent = db.Column(db.String(2), nullable=True)
# Flags
creative = db.Column(db.Boolean, nullable=False)
is_dedicated = db.Column(db.Boolean, nullable=False)
damage_enabled = db.Column(db.Boolean, nullable=False)
pvp_enabled = db.Column(db.Boolean, nullable=False)
password_required = db.Column(db.Boolean, nullable=False)
rollback_enabled = db.Column(db.Boolean, nullable=False)
can_see_far_names = db.Column(db.Boolean, nullable=False)
address_verification_required = db.Column(db.Boolean, nullable=False, default=False)
@staticmethod
def find_from_json(obj):
try:
if "world_uuid" in obj:
return Server.query.filter_by(world_uuid=obj["world_uuid"]).one()
return Server.query.filter_by(address=obj["address"], port=obj["port"]).one()
except NoResultFound:
return None
@staticmethod
def create_or_update(obj):
server = Server.find_from_json(obj)
if server is not None:
server.update(obj)
else:
server = Server()
server.update(obj, True)
db.session.add(server)
return server
def update(self, obj, initial=False):
now = datetime.now()
action = obj.get("action", "start")
assert action != "delete"
if "clients_list" in obj:
num_clients = len(obj["clients_list"])
else:
num_clients = obj["clients"]
if initial:
# Values set only when the server is first created
assert action == "start"
self.world_uuid = obj.get("world_uuid")
self.clients_top = num_clients
self.total_uptime = 0
else:
self.clients_top = max(self.clients_top, num_clients)
if action == "start":
# Fields updated only on startup
self.start_time = now
self.mods = "\n".join(obj.get("mods", []))
self.mapgen = obj.get("mapgen")
self.default_privs = obj.get("privs")
self.is_dedicated = obj.get("dedicated", False)
self.rollback_enabled = obj.get("rollback", False)
self.can_see_far_names = obj.get("can_see_far_names", False)
self.online = True
self.address = obj["address"]
self.port = obj.get("port", 30000)
self.announce_ip = obj["ip"]
self.server_id = obj.get("server_id")
self.clients = "\n".join(obj["clients_list"])
self.clients_max = obj["clients_max"]
self.game_time = obj["game_time"]
self.lag = obj.get("lag")
self.ping = obj["ping"]
self.version = obj["version"]
self.proto_min = obj["proto_min"]
self.proto_max = obj["proto_max"]
self.game_id = obj["gameid"]
self.url = obj.get("url")
self.name = obj["name"]
self.description = obj["description"]
if initial:
self.popularity = num_clients
else:
pop_factor = app.config["POPULARITY_FACTOR"]
self.popularity = self.popularity * pop_factor + \
num_clients * (1 - pop_factor)
self.geo_continent = obj.get("geo_continent")
self.creative = obj.get("creative", False)
self.damage_enabled = obj.get("damage", False)
self.pvp_enabled = obj.get("pvp", False)
self.password_required = obj.get("password", False)
self.last_update = now
if obj["address_verified"]:
self.address_verification_required = True
def as_json(self):
obj = {
"address": self.address,
"can_see_far_names": self.can_see_far_names,
"clients_list": self.clients.split("\n") if self.clients else [],
"clients_max": self.clients_max,
"clients_top": self.clients_top,
"creative": self.creative,
"damage": self.damage_enabled,
"dedicated": self.is_dedicated,
"description": self.description,
"game_time": self.game_time,
"gameid": self.game_id,
"name": self.name,
"password": self.password_required,
"ping": self.ping,
"pop_v": self.popularity,
"port": self.port,
"proto_max": self.proto_max,
"proto_min": self.proto_min,
"pvp": self.pvp_enabled,
"rollback": self.rollback_enabled,
"uptime": int((datetime.utcnow() - self.start_time).total_seconds()),
"version": self.version,
}
# Optional fields
if self.geo_continent is not None:
obj["geo_continent"] = self.geo_continent
if self.lag is not None:
obj["lag"] = self.lag
if self.mapgen is not None:
obj["mapgen"] = self.mapgen
if self.mods is not None:
obj["mods"] = self.mods.split("\n") if self.mods else []
if self.default_privs is not None:
obj["privs"] = self.default_privs
if self.server_id is not None:
obj["server_id"] = self.server_id
if self.url is not None:
obj["url"] = self.url
return obj
def set_offline(self):
now = datetime.utcnow()
self.online = False
self.total_uptime += (now - self.start_time).total_seconds()
self.down_time = now
class Stats(db.Model):
"""
This table has only a single row storing all of the global statistics.
"""
id = db.Column(db.Integer, primary_key=True)
max_servers = db.Column(db.Integer, nullable=False, default=0)
max_clients = db.Column(db.Integer, nullable=False, default=0)
@staticmethod
def get():
try:
return Stats.query.filter_by(id=1).one()
except NoResultFound:
stats = Stats()
stats.id = 1
db.session.add(stats)
return stats

View File

@@ -1,98 +0,0 @@
import time
import socket
from .app import app
from .util import get_addr_info
# Initial 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)
PING_PACKET = b"\x4f\x45\x74\x03\x00\x00\x00\x01"
def get_ping_reply(data):
# [0] u32 protocol_id (PROTOCOL_ID)
# [4] session_t sender_peer_id
# [6] u8 channel
# [7] u8 type (PACKET_TYPE_RELIABLE)
# [8] u16 sequence number
# [10] u8 type (PACKET_TYPE_CONTROL)
# [11] u8 controltype (CONTROLTYPE_SET_PEER_ID)
# [12] session_t peer_id_new
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)
return b"\x4f\x45\x74\x03" + peer_id + b"\x00\x00\x03"
def ping_server_addresses(address, port):
pings = []
addr_info = get_addr_info(address, port)
for record in addr_info:
ping = server_up(record)
if not ping:
app.logger.warning("Could not connect to %s:%d using resolved info %r.",
address, port, record)
return None
pings.append(ping)
return pings
def ping_server(sock):
sock.send(PING_PACKET)
# Receive reliable packet of type CONTROL, subtype SET_PEER_ID,
# with our assigned peer id as data.
start = time.time()
data = sock.recv(1024)
end = time.time()
if not data:
return None
sock.send(get_ping_reply(data))
return end - start
# Returns ping time in seconds (up) or None (down).
def server_up(info):
"""Pings a Minetest server to check if it is online.
"""
try:
sock = socket.socket(info[0], info[1], info[2])
sock.settimeout(2)
sock.connect(info[4])
except OSError:
return None
attempts = 0
pings = []
while len(pings) < 3 and attempts - len(pings) < 3:
attempts += 1
try:
ping = ping_server(sock)
if ping is not None:
pings.append(ping)
except socket.timeout:
pass
except ConnectionRefusedError:
return None
except OSError:
return None
sock.close()
if len(pings) != 0:
return min(pings)
return None

View File

@@ -1,79 +0,0 @@
import json
import os
from datetime import datetime
from .app import app, celery, db
from .models import Server, Stats
from .ping import ping_server_addresses
from .util import get_geo_continent, server_ranking
@celery.task
def update_server(obj):
geo_continent = get_geo_continent(obj["addr_info"][-1][4][0])
if geo_continent is not None:
obj["geo_continent"] = geo_continent
# Ensure that a Minetest server is actually reachable on all addresses
pings = ping_server_addresses(obj["address"], obj["port"])
if pings is None:
return
# Use average ping
obj["ping"] = sum(pings) / len(pings)
Server.create_or_update(obj)
db.session.commit()
def update_list_json():
online_servers = Server.query.filter_by(online=True).all()
online_servers.sort(key=server_ranking, reverse=True)
server_list = [s.as_json() for s in online_servers]
num_clients = 0
for server in server_list:
num_clients += len(server["clients_list"])
stats = Stats.get()
stats.max_servers = max(len(server_list), stats.max_servers)
stats.max_clients = max(num_clients, stats.max_clients)
list_path = os.path.join(app.static_folder, "list.json")
# Write to temporary file, then do an atomic replace so that clients don't
# see a truncated file if they load the list just as it's being updated.
with open(list_path + "~", "w") as fd:
debug = app.config["DEBUG"]
json.dump({
"total": {"servers": len(server_list), "clients": num_clients},
"total_max": {"servers": stats.max_servers, "clients": stats.max_clients},
"list": server_list,
},
fd,
indent="\t" if debug else None,
separators=(',', ': ') if debug else (',', ':')
)
os.replace(list_path + "~", list_path)
@celery.task
def update_list():
cutoff = datetime.utcnow() - app.config["PURGE_TIME"]
expired_servers = Server.query.filter(
Server.online == True,
Server.last_update < cutoff
)
for server in expired_servers:
server.set_offline()
update_list_json()
db.session.commit()
@celery.on_after_configure.connect
def setup_periodic_tasks(sender, **kwargs):
sender.add_periodic_task(60, update_list.s(), name='Update server list')

View File

@@ -1,10 +0,0 @@
Server address does not include a DNS record for the IP that the announcement was sent from.
Announce IP: {{ announce_ip }}
Address records: {{ valid_addresses | join ", " }}
Help: This is usually because your server is only listening on IPv4 but your announcement is being sent over IPv6.
If that is the case there are two ways to fix this:
1. (preferred) Set ipv6_server = true in your server config to listen on IPv6 and add your IPv6 address to DNS as an AAAA record.
On Linux this allows clients to connect using both IPv4 and IPv6 (unless you have enabled net.ipv6.bind6only).
On other operating systems this option may only work with IPv6 clients and you'll have to use the second option if you want to support IPv4.
2. Set bind_address = 0.0.0.0 in your server config to force IPv4 only, the announce will then be sent from the IPv4 address.

View File

@@ -1,213 +0,0 @@
import re
import socket
from datetime import datetime, timedelta
from .app import app
try:
import maxminddb
MAXMIND_DB = app.config.get("MAXMIND_DB", None)
if MAXMIND_DB is not None:
geoip_reader = maxminddb.open_database(MAXMIND_DB, 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 point "
"the MAXMIND_DB setting to the .mmdb file."
)
geoip_reader = None
except ImportError:
app.logger.warning("maxminddb not available, GeoIP will not work.")
UUID_RE = re.compile('^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')
def check_ban(announce_ip, address, port):
if "%s/%d" % (announce_ip, port) in app.config["BANNED_SERVERS"]:
return True
if address != announce_ip:
# Normalize address for ban checks
address = address.lower().rstrip(".")
if f"{address}/{port}" in app.config["BANNED_SERVERS"] or \
address in app.config["BANNED_SERVERS"]:
return True
return False
def get_addr_info(address, port):
try:
return socket.getaddrinfo(
address,
port,
type=socket.SOCK_DGRAM,
proto=socket.SOL_UDP)
except socket.gaierror:
app.logger.warning("Unable to get address info for [%s]:%d.",
address, port)
return None
def verify_announce(addr_info, address, announce_ip):
if address == announce_ip:
return True
addresses = set(data[4][0] for data in addr_info)
if not announce_ip in addresses:
app.logger.warning(
"Server address %r does not resolve to announce IP %r (address valid for %r).",
address, announce_ip, addresses)
return False
return True
def get_geo_continent(ip):
if ip.startswith("::ffff:"):
ip = ip[7:]
if reader is None:
return
try:
geo = geoip_reader.get(ip)
except ValueError:
return
if geo and "continent" in geo:
return geo["continent"]["code"]
else:
app.logger.warning("Unable to get GeoIP Continent data for %s.", ip)
return None
# fieldName: (Required, Type, SubType)
fields = {
"action": (True, "str"),
"world_uuid": (False, "str"),
"address": (False, "str"),
"port": (False, "int"),
"clients_max": (True, "int"),
"uptime": (True, "int"),
"game_time": (True, "int"),
"lag": (False, "float"),
"clients_list": (True, "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 check_request_json(obj):
"""Checks the types and values of fields in the request.
Returns error string or None.
"""
for name, data in fields.items():
# Delete optional string fields sent as empty strings
if not data[0] and data[1] == "str" and obj.get(name) == "":
del obj[name]
if not name in obj:
if data[0]:
return f"Required field '{name}' is missing."
continue
type_str = type(obj[name]).__name__
if type_str != data[1]:
return f"Field '{name}'' has incorrect type (expected {data[1]} found {type_str})."
if len(data) >= 3:
for item in obj[name]:
subtype_str = type(item).__name__
if subtype_str != data[2]:
return f"Entry in field '{name}' has incorrect type (expected {data[2]} found {subtype_str})."
if "url" in obj:
url = obj["url"]
if not any(url.startswith(p) for p in ["http://", "https://", "//"]):
return "Field 'url' does not match expected format."
if "world_uuid" in obj and not UUID_RE.match(obj["world_uuid"]):
return "Field 'world_uuid' does not match expected format."
return None
def server_ranking(server):
now = datetime.utcnow()
points = 0
clients = server.clients.split('\n')
# 1 per client, but only 1/8 per "guest" client
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
# 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 len(clients) > cap:
points -= len(clients) - cap
# 1 per month of age, limited to 8
points += min(8, (now - server.first_seen) / timedelta(months=1))
# 1/2 per average client, limited to 4
points += min(4, server.popularity / 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)
ONE_HOUR = timedelta(hours=1)
uptime = now - server.start_time
if uptime < ONE_HOUR:
# Only apply penalty if the server was down for more than an hour
down_too_long = True
if server.down_time is not None:
down_too_long = (server.start_time - server.down_time) > ONE_HOUR
if down_too_long:
points -= ((ONE_HOUR - uptime) / ONE_HOUR) * 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

View File

@@ -1,109 +0,0 @@
import json
from flask import render_template, request, send_from_directory, make_response
from .app import app, db
from .models import Server
from .tasks import update_server
from .util import check_ban, check_request_json, get_addr_info, get_geo_continent, verify_announce
@app.route("/")
def index():
return app.send_static_file("index.html")
@app.route("/list")
def server_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", max_age=0)
@app.route("/geoip")
def geoip():
continent = get_geo_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.route("/announce", methods=["GET", "POST"])
def announce():
announce_ip = request.remote_addr
if announce_ip.startswith("::ffff:"):
announce_ip = announce_ip[7:]
if announce_ip in app.config["BANNED_IPS"]:
return "Banned.", 403
data = request.values["json"]
if len(data) > 8192:
return "JSON data is too big.", 413
try:
obj = json.loads(data)
except json.JSONDecodeError as e:
return "Failed to decode JSON: " + e.msg, 400
if not isinstance(obj, dict):
return "JSON data is not an object.", 400
action = obj.get("action")
if action not in ("start", "update", "delete"):
return "Action field is invalid or missing.", 400
obj["ip"] = announce_ip
if not obj.get("address"):
obj["address"] = announce_ip
obj.setdefault("port", 30000)
if check_ban(announce_ip, obj["address"], obj["port"]):
return "Banned", 403
server = Server.find_from_json(obj)
if action == "delete":
if not server:
return "Server not found."
server.set_offline()
db.session.commit()
return "Removed from server list."
# Delete message does not require most fields
error_str = check_request_json(obj)
if error_str is not None:
return "Invalid JSON data: " + error_str, 400
if action == "update" and not server:
if app.config["ALLOW_UPDATE_WITHOUT_OLD"]:
action = "start"
else:
return "Server to update not found.", 404
addr_info = get_addr_info(obj["address"], obj["port"])
if addr_info is None:
return f"Failed to resolve server address {obj['address']!r}.", 400
valid = False
if "world_uuid" not in obj:
valid = verify_announce(addr_info, obj["address"], obj["ip"])
if not valid and server and server.address_verification_required:
return render_template("address_verification_failed.txt",
announce_ip=announce_ip,
valid_addresses=[data[4][0] for data in addr_info]), 400
obj["address_verified"] = valid
obj["addr_info"] = addr_info
update_server.delay(obj)
return "Done.", 202

View File

@@ -2,10 +2,11 @@
<html>
<head>
<meta charset="utf-8">
<title>Minetest server list</title>
<title>Luanti server list</title>
<link rel="stylesheet" href="modern-normalize.min.css">
<style>
body {
font-family: Roboto, Ubuntu, "Segoe UI", sans-serif;
margin: .5em;
}
a {
color: #336B87;
@@ -13,6 +14,10 @@
a:visited {
color: #336BA1;
}
hr {
border: 0;
border-top: 3px solid #53ac56;
}
@media only screen and (max-width: 1024px) {
#server_list table .version, #server_list table .flags, #server_list table .uptime {
display: none;
@@ -24,6 +29,8 @@
</script>
</head>
<body>
<span class="h"><strong>Luanti server list</strong> | <a href="https://www.luanti.org/get-involved/#reporting-issues">Contact</a> | <a href="https://www.luanti.org/app-privacy-policy/">Privacy</a></span>
<hr />
<div id="server_list"></div>
<script src="list.js"></script>
</body>

View File

@@ -1,16 +1,21 @@
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;
if (!master)
master = {};
if (!master.root)
master.root = window.location.href;
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;
master.cached_json = null;
// Utility functions used by the templating code
function humanTime(seconds) {
if (typeof(seconds) != "number") return '?';
if (typeof(seconds) != "number")
return '?';
var conv = {
y: 31536000,
d: 86400,
@@ -26,25 +31,25 @@ function humanTime(seconds) {
}
function escapeHTML(str) {
if(!str) return 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 addrStr = server.address;
if (addrStr.indexOf(':') != -1)
addrStr = '[' + addrStr + ']';
var shortStr = addrStr;
addrStr += ':' + server.port;
var str = '<span'
if (shortStr.length > 25) {
shortStr = shortStr.substr(0, 23) + "&hellip;";
str += ' title="' + addrStr + '"'
if (shortStr.length > 26) {
shortStr = shortStr.substring(0, 25) + "\u2026";
str += ' title="' + escapeHTML(addrStr) + '"'
}
if (server.port != 30000)
shortStr += ':' + server.port;
return str + '>' + shortStr + '</span>';
return str + '>' + escapeHTML(shortStr) + '</span>';
}
function tooltipString(str) {
@@ -53,30 +58,36 @@ function tooltipString(str) {
}
function hoverList(name, list) {
if (!list || list.length == 0) return '';
if (!list || list.length == 0)
return '';
var str = '<div class="mts_hover_list">'
str += '<b>' + name + '</b> (' + list.length + ')<br />';
str += '<b>' + escapeHTML(name) + '</b> (' + list.length + ')<br />';
for (var i in list) {
str += escapeHTML(list[i]) + '<br />';
}
return str + '</div>';
}
function hoverString(name, string) {
if (!string) return '';
function hoverString(name, str) {
if (!str)
return '';
if (typeof(str) != 'string')
str = str.toString();
return '<div class="mts_hover_list">'
+ '<b>' + name + '</b>:<br />'
+ escapeHTML(string) + '<br />'
+ '<b>' + escapeHTML(name) + '</b>:<br />'
+ escapeHTML(str) + '<br />'
+ '</div>';
}
function constantWidth(str, width) {
return '<span class="mts_cwidth" style="width:' + width + 'em;">' + str + '</span>';
if (typeof(str) != 'string')
str = str.toString();
return '<span class="mts_cwidth" style="width:' + width + 'em;">' + escapeHTML(str) + '</span>';
}
// Code that fetches & displays the actual list
function draw(json) {
master.draw = function(json) {
if (json == null)
return;
@@ -95,27 +106,32 @@ function draw(json) {
}
var html = window.render.servers(json);
jQuery(master.output).html(html);
jQuery('#server_list').html(html);
jQuery('.proto_select', master.output).on('change', function(e) {
jQuery('.proto_select', '#server_list').on('change', function(e) {
master.proto_range = e.target.value;
draw(master.cached_json); // re-render
master.draw(master.cached_json); // re-render
});
}
};
function get() {
master.get = function() {
jQuery.getJSON(master.list_url, function(json) {
master.cached_json = json;
draw(json);
master.draw(json);
});
}
};
function loaded(){
if (!master.no_refresh) {
setInterval(get, 60 * 1000);
}
get();
}
master.loaded = function() {
if (!master.no_refresh)
setInterval(master.get, 60 * 1000);
master.get();
};
master.showAll = function() {
delete master.min_clients;
delete master.limit;
master.get();
};
// https://github.com/pyrsmk/toast
@@ -123,8 +139,8 @@ this.toast=function(){var e=document,t=e.getElementsByTagName("head")[0],n=this.
toast(master.root + 'style.css', master.root + 'servers.js', function() {
if (typeof(jQuery) != 'undefined')
return loaded();
return master.loaded();
else
toast('//ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js', loaded);
toast('//ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js', master.loaded);
});

9
static/modern-normalize.min.css vendored Normal file
View File

@@ -0,0 +1,9 @@
/**
* Minified by jsDelivr using clean-css v5.3.2.
* Original file: /npm/modern-normalize@3.0.1/modern-normalize.css
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
/*! modern-normalize v3.0.1 | MIT License | https://github.com/sindresorhus/modern-normalize */
*,::after,::before{box-sizing:border-box}html{font-family:system-ui,'Segoe UI',Roboto,Helvetica,Arial,sans-serif,'Apple Color Emoji','Segoe UI Emoji';line-height:1.15;-webkit-text-size-adjust:100%;tab-size:4}body{margin:0}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Consolas,'Liberation Mono',Menlo,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-color:currentcolor}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}legend{padding:0}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}
/*# sourceMappingURL=/sm/d2d8cd206fb9f42f071e97460f3ad9c875edb5e7a4b10f900a83cdf8401c53a9.map */

View File

@@ -7,8 +7,8 @@
{{? master.show_proto_select}}
, Protocol: <select class="proto_select">
<option value="">All</option>
<option value="[11,32]" {{? master.proto_range=='[11,32]'}}selected{{?}}>11-32 (Minetest 0.4)</option>
<option value="[37,99]" {{? master.proto_range=='[37,99]'}}selected{{?}}>37+ (Minetest 5.x)</option>
<option value="[11,32]" {{? master.proto_range=='[11,32]'}}selected{{?}}>11-32 (0.4 series)</option>
<option value="[37,99]" {{? master.proto_range=='[37,99]'}}selected{{?}}>37+ (5.0 or newer)</option>
</select>{{?}}
</div>
{{?}}
@@ -16,7 +16,7 @@
<thead><tr>
{{? !master.no_address}}<th>Address[:Port]</th>{{?}}
{{? !master.no_clients}}<th>Players / Max{{? !master.no_avgtop}}<br/>Average / Top{{?}}</th>{{?}}
{{? !master.no_version}}<th class="version">Version, Subgame[, Mapgen]</th>{{?}}
{{? !master.no_version}}<th class="version">Version, Game, Mapgen</th>{{?}}
{{? !master.no_name}}<th>Name</th>{{?}}
{{? !master.no_description}}<th>Description</th>{{?}}
{{? !master.no_flags}}<th class="flags">Flags</th>{{?}}
@@ -26,15 +26,15 @@
<tbody>
{{~it.list :server:index}}
{{ if (master.limit && index + 1 > master.limit) break;}}
{{ if (master.min_clients && server.clients_list.length < master.min_clients) continue;}}
{{ 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.length > 0 }} mts_hover_list_text{{?}}">
{{=constantWidth(server.clients_list.length + '/' + server.clients_max, 3.4)}}
<td class="clients{{? server.clients_list && server.clients_list.length > 0}} mts_hover_list_text{{?}}">
{{=constantWidth(server.clients + '/' + server.clients_max, 3.4)}}
{{? !master.no_avgtop}} {{=constantWidth(Math.floor(server.pop_v) + '/' + server.clients_top, 3.4)}}{{?}}
{{=hoverList("Clients", server.clients_list)}}
</td>{{?}}
@@ -47,7 +47,7 @@
{{? !master.no_name}}
<td class="name">
{{? server.url}}
<a href="{{!server.url}}">{{=tooltipString(server.name)}}</a>
<a href="{{!server.url}}" target="_blank">{{=tooltipString(server.name)}}</a>
{{??}}
{{=tooltipString(server.name)}}
{{?}}
@@ -63,8 +63,6 @@
{{=server.damage ? 'Dmg ' : ''}}
{{=server.pvp ? 'PvP ' : ''}}
{{=server.password ? 'Pwd ' : ''}}
{{=server.rollback ? 'Rol ' : ''}}
{{=server.can_see_far_names ? 'Far ' : ''}}
</td>{{?}}
{{? !master.no_uptime}}
<td class="uptime">
@@ -79,5 +77,5 @@
</tbody>
</table>
{{? master.min_clients || master.limit}}
<a href="javascript:delete master.min_clients; delete master.limit; get();">Show more...</a>
<a href="javascript:master.showAll()">Show all...</a>
{{?}}

View File

@@ -50,7 +50,7 @@
max-width: 32ch;
}
#server_list td.description {
max-width: 64ch;
max-width: 70ch;
}
.mts_hover_list {