Sitelet https://github.com/VortexFPS/XonoticBanServer
Skip to content

About

A ban list provider for Xonotic dedicated servers - the server half of Xonotic's built-in ban syncing

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

XonoticBanServer

A ban list provider for Xonotic dedicated servers — the missing server half of Xonotic's built-in ban syncing.

Xonotic can share bans between servers over HTTP: each server reports the bans it places to a "ban list provider", and periodically pulls back bans placed by the servers it trusts. Xonotic ships the client half (g_ban_sync_*, in qcsrc/server/ipban.qc) but no provider, and there is no public, project-run list to join. This is a provider you can run yourself.

  • One file, no dependencies beyond the Python 3 standard library.
  • State is a single JSON file. No database, no config file.
  • Several game servers can share one IP address and stay told apart.
  • 57 tests covering the wire format, the allowlist, server identity, reply size, proxy attribution and concurrent writes.

Quick start

python3 bansync.py --allow 192.0.2.10,192.0.2.11

Then on each game server, in ~/.xonotic/data/server.cfg:

set g_ban_sync_uri "http://bans.example.net:8080/"
set g_ban_sync_trusted_servers "192.0.2.11 192.0.2.12"   // the OTHER servers, never this one
set g_ban_sync_interval 5

Ban someone with a reason — a ban with an empty reason is never reported to the provider:

sv_cmd kickban 3 86400 3 aimbot

Within one sync interval the other servers print Ban list syncing: accepted ban of ….

A full setup and troubleshooting guide, including how to read the console output when syncing fails, lives at https://vortexfps.github.io/VortexArena/ban-sync.html.

Options

Flag Environment Default Meaning
--bind, -b BANSYNC_BIND 0.0.0.0 Address to listen on.
--port, -p BANSYNC_PORT 8080 Port to listen on.
--store, -s BANSYNC_STORE bans.json Path to the JSON ban store.
--allow BANSYNC_ALLOW (any) Game servers allowed to place bans, as IP or NAME@IP. Repeatable or comma-separated.
--trusted-proxy BANSYNC_TRUSTED_PROXIES (none) Reverse proxy IPs whose X-Forwarded-For header is honoured.
--verbose, -v BANSYNC_VERBOSE off Also log one line per request, including every poll.

--allow

Only the listed servers may call action=ban or action=unban; anything else gets 403. Reading the list is always open, since that is what the game servers poll. With no --allow the provider accepts bans from any host that can reach the port and logs a warning at startup — fine on a private network, not fine on the public internet.

Logging

By default the provider logs what it decided — each ban, each unban, each refusal — and nothing else. It does not log a line per request: every game server polls on a timer, and a ban's query string carries a player's IP address, so the routine traffic would bury the decisions and fill the journal with player addresses. Pass --verbose to get the per-request access line back when you are debugging a server that will not sync.

Several game servers on one address

Two Xonotic servers on one host, 192.0.2.10:26000 and 192.0.2.10:26001, both reach the provider from the same IP. Left alone that breaks three ways: their bans overwrite each other, neither can exclude itself from its own trusted-servers list, and an unban from one deletes the other's bans.

Give each one a name. The provider only ever sees the HTTP connection, whose source port is ephemeral rather than the game port, so it cannot tell them apart on its own — each server has to say who it is:

python3 bansync.py --allow alpha@192.0.2.10,bravo@192.0.2.10
// server alpha
set g_ban_sync_uri "http://bans.example.net:8080/?id=alpha"
set g_ban_sync_trusted_servers "bravo"

// server bravo
set g_ban_sync_uri "http://bans.example.net:8080/?id=bravo"
set g_ban_sync_trusted_servers "alpha"

Both cvars have to change. g_ban_sync_uri carries the name on every request — Xonotic appends its own parameters with & when the URI already has a query string. But g_ban_sync_trusted_servers is what becomes the servers= parameter the provider filters on, so a server still asking for 192.0.2.10 gets nothing back once bans are filed under alpha and bravo. An empty trusted-servers list disables syncing outright.

This needs no change to Xonotic. Both mechanisms it relies on — appending to a URI that already has a query string, and matching the fourth reply line as an opaque token rather than parsing it as an address — have been in qcsrc/server/ipban.qc since the first commit of the Xonotic repository in March 2010, so every released version supports it.

A name may contain letters, digits, dot, dash and underscore, up to 32 characters. Nothing else: it travels unescaped, and Xonotic splits the trusted list on whitespace and matches it between semicolons.

Naming is optional and per-address. A server that sends no ?id= is attributed to its IP exactly as before, so single-server hosts need no changes at all. Naming is also checked against the source address — a host may only claim a name declared for it in --allow, or anyone reaching the port could lift another server's bans by naming it. Once an address has any name declared, a request from it must carry a recognised one, since an unnamed write would be ambiguous between the servers sharing that address.

Migrating an existing bans.json

Bans already stored are filed under a bare IP. Once that address gains names, nothing asks for the old key any more and those entries go unread until they expire on their own. There is no migration step — bans are short-lived by nature, so the simplest course is to switch over and let them lapse. If you would rather not lose them, stop the provider and rewrite the "server" field of the affected rows in bans.json to whichever name should own them before starting it again.

Unbanning

unban only removes bans placed by the server that asks, which is what makes an unban on the originating server propagate outward instead of being fought over. Two consequences worth knowing before you file a bug:

  • Xonotic sends unbans it did not mean. When it recycles a ban slot holding an entry that merely expired, it reports that as an unban (Ban_Delete via Ban_Insert in ipban.qc). Because a ban can only be lifted by whoever placed it, these strays land as no-ops. Without per-server names, two servers sharing an address share an owner and the strays delete live bans — which is the strongest reason to name co-located servers even if nothing else here applies.
  • You cannot unban someone else's ban. Unban a player on alpha when bravo placed the ban and they will vanish locally, then reappear on alpha's next sync from bravo. That is working as intended — bravo's ban is bravo's call — but it looks like the provider ignored you. Unban on the server that placed it, which the reason line of the synced ban names.

Running it properly

bansync.service is a systemd unit for the provider; Caddyfile.example and nginx.conf.example are TLS reverse proxies for it. The unit uses DynamicUser=yes and StateDirectory=bansync, so there is no account to create and the store lands in /var/lib/bansync/bans.json.

sudo cp bansync.py /usr/local/bin/bansync.py
sudo cp bansync.service /etc/systemd/system/
sudo systemctl enable --now bansync

Edit the flags in the unit before enabling it. --allow is the one to get right first: without it the provider takes bans from anything that can reach the port.

Behind a reverse proxy

Every request carries a player's address in the query string, and action=ban carries the reason with it. Terminate TLS in front of the provider if it is reachable over the public internet. A proxy also gives it a hostname that outlives the machine, which matters because that hostname is what every game server operator ends up pasting into a config.

Whichever proxy you use, two things have to line up: the provider has to know the proxy's address, and the proxy has to send X-Forwarded-For.

Xonotic can fetch HTTPS, but not a self-signed certificate

Point g_ban_sync_uri at an https:// URL and it works. DarkPlaces loads the system libcurl at runtime and permits http, https and ftp; anything else is refused as a "nasty URL scheme" (Curl_Begin in darkplaces/libcurl.c).

It sets no certificate options at all, so libcurl's defaults apply: the certificate is verified against the host's CA store, and there is no cvar to relax that. A self-signed certificate fails. Because syncing runs on a timer rather than at startup, the symptom is a ban list that never fills rather than an error anyone is watching for. Use a certificate from a CA the game server's host already trusts.

--trusted-proxy

The provider attributes each ban to the IP that placed it, and game servers pull bans by that attribution. Behind a reverse proxy every request appears to come from the proxy, so without this flag a proxied deployment attributes every ban to the proxy's own address and syncs nothing.

Pass the proxy's address and the provider will read the real client from X-Forwarded-For, taking the rightmost hop that is not itself a trusted proxy. The header is honoured only when the connection comes from a configured proxy — otherwise any client could name its own address and impersonate a trusted server.

Caddy

bans.example.net {
	reverse_proxy 127.0.0.1:8080
}

That is the whole file. Caddyfile.example is the same block with the reasoning in comments. Caddy obtains and renews the certificate, redirects http:// to https://, and sets X-Forwarded-For without being told to, so --trusted-proxy 127.0.0.1 on the provider is the only thing left to match up.

Caddy writes no access log unless you configure one, which is the right default here: these request URIs contain player addresses. If you turn it on, send it somewhere you are willing to keep them.

nginx

nginx.conf.example is a complete server block. The two lines that carry the load:

proxy_pass http://127.0.0.1:8080;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

nginx sets no forwarding header on its own, so the second line is not optional. It also logs the full request line by default, which writes every banned player's address to disk, so the example turns access_log off. Certificates are yours to arrange; the example reads a certbot path.

What to bind to, and what never to allow

When the proxy is the only thing that talks to the provider, bind loopback:

python3 bansync.py --bind 127.0.0.1 --trusted-proxy 127.0.0.1 --allow 192.0.2.10,192.0.2.11

Two ways a proxied deployment goes wrong, neither of which announces itself:

  • Never list the proxy's own address in --allow. When a request arrives from a trusted proxy carrying no usable X-Forwarded-For, attribution falls back to the peer, which is the proxy. Whatever the proxy's address is permitted to do becomes permitted to anyone who can reach the proxy, starting the day a config change stops that header being set.
  • A game server on the provider's own host should not connect over loopback when loopback is also the proxy's address. Unnamed, it is attributed to the proxy and syncs nothing. Named it works, but alpha@127.0.0.1 means anyone reaching the proxy can claim to be alpha the moment X-Forwarded-For goes missing, for the reason above. Give it an address of its own — the host's LAN address, or 0.0.0.0 behind a firewall that keeps the port off the internet — and point the server at that.

The protocol

Three GET requests, all URL-encoded. hostname is the reporting server's name.

?action=ban&hostname=..&ip=..&duration=<seconds>&reason=..
?action=unban&hostname=..&ip=..
?action=list&hostname=..&servers=1.2.3.4;5.6.7.8;..

The reply to list is plain text, four LF-separated lines per ban:

203.0.113.0     <- address: 1-4 octets, an IPv6 prefix, or a 44-char crypto player id
86321           <- seconds remaining
aimbot          <- reason
192.0.2.10      <- the game server that registered the ban, by IP or by name

Xonotic discards the whole reply if it starts with < (an HTML error page), contains a carriage return, or has a line count that is not divisible by four. An address given as fewer than four octets is a network mask: 203.0.113 covers the whole /24.

The fourth line is never parsed as an address. Xonotic matches it as an opaque token against g_ban_sync_trusted_servers, which is what lets a server be identified by name — see Several game servers on one address.

?id=.. is ours, not Xonotic's. Xonotic passes it through because it appends its own parameters to whatever g_ban_sync_uri already contains.

The store

bans.json is a plain JSON array, safe to read while the provider is running — writes go to a temporary file and are moved into place, so a reader never sees a half-written list.

{
 "ip": "203.0.113.0",
 "expires": 1786237425.698,
 "reason": "aimbot",
 "server": "alpha",
 "hostname": "Vortex CTF"
}

server is the identity a ban is filed under and the only one of these that game servers ever see. hostname is whatever the reporting server had hostname set to at the time, recorded so you can tell which machine alpha actually is; it is never sent to anyone. Entries written before this field existed simply lack it.

Tests

python3 -m unittest -v

No fixtures or mocks: each test starts a real provider on a loopback port and talks to it over HTTP.

Notes and limits

  • A Xonotic server holds 256 bans total, local and synced together. Past that a new ban only lands if it outlasts the entry it would replace.
  • A list reply must stay under 16 KB. Xonotic copies the whole response into a fixed MAX_INPUTLINE buffer before splitting it (VM_tokenizebyseparator in the engine), and the copy truncates silently. Truncation lands mid-ban, the line count stops being divisible by four, and Xonotic then discards the entire list — so overflowing costs every ban in the reply, not the one that overflowed. The provider therefore fills to a 15,500-byte budget, longest-remaining ban first, and logs a warning naming how many it left out. How many fit depends on reason length: roughly 420 with short ones, but only about 70 at the 200-character maximum, below the 256-ban limit above. If you see that warning, shorten your ban reasons — bans are being withheld from the servers that asked for them.
  • Ban durations are taken as sent. A server allowed to write can pin a slot with an absurd duration, so keep --allow tight.
  • Whoever runs the provider sees every banned address and the hostname of every reporting server.

Licence

GPL-3.0-or-later. See COPYING.

About

A ban list provider for Xonotic dedicated servers - the server half of Xonotic's built-in ban syncing

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages