- Go 99.4%
- Makefile 0.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
The repository moved from Gogs (gogs.netdome.biz) to a knot on Tangled (knot.netdome.biz). The Gogs host could not serve SHA-256 repositories anyway: gogs/git-module parses object IDs as fixed 40-character SHA-1 hex strings. The module path is now tangled.org/paulklumpp.tngl.sh/go-masterserver-client-mcp, which is the vanity path that carries a go-import meta tag, so 'go get' resolves it. Also ignore the stray build artifact at the repository root. |
||
| client | ||
| cmd/quake-masterserver | ||
| internal | ||
| .gitignore | ||
| AGENTS.md | ||
| env.example | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| Makefile | ||
| README.md | ||
quake-masterserver
Master servers for the classic Quake family — Quake II, QuakeWorld, Heretic II, Doom 3 and Quake 3 Arena — reimplemented in Go from the C plugins of the lasange-system masterserver. No Docker, one static binary.
In addition to the master server itself, this module ships:
- a public Go client library (
./client) so other projects can query master and game servers over UDP, and - an MCP server (streamable HTTP only) that exposes the in-memory server lists of every enabled game as tools.
Features
- One binary, all five game masters, each independently switchable via
environment (
.envis supported). - Official master ports by default: Q2
27900, QW27000, H228900, D327650, Q327950+ MOTD27951. - The master probes the registered game servers itself over UDP
(
status/print,n-replies, the native Q3/D3getstatus/getInfoheartbeat cycle) and caches the results (hostname, map, players, cvars) in memory. - MCP tools per enabled game:
list_<game>_servers— plainip:portlist,<game>_server_info— JSON with server name, map, players and all collected cvars (optionally limited to oneaddr),remove_server— admin tool, only registered whenMS_ENABLE_ADMIN=true.
- DoS-hardened UDP engine: per-source-IP rate limiting, a bounded rate-limiter table, a per-game server cap, and a 1400-byte packet limit.
- HTTP-only MCP endpoint with optional bearer auth; serving on a non-loopback address without a token is refused.
Supported games
| Game | UDP port | Heartbeat | Server list query | Info source |
|---|---|---|---|---|
| Quake II | 27900 | \xff..heartbeat + cvars |
query\n\0 |
heartbeat cvars, on demand status→print |
| QuakeWorld | 27000 | a\n<seq>\n<players>\n |
c\n\0 |
on demand status→n |
| Heretic II | 28900 | \xff..heartbeat + cvars |
query\n\0 |
heartbeat cvars, on demand status→print |
| Doom 3 | 27650 | \xff\xffheartbeat\0 + getInfo |
getServers + LE32 protocol |
native getInfo cycle (infoResponse pairs) |
| Quake 3 | 27950, MOTD 27951 | \xff..heartbeat + getstatus <challenge> |
getservers <proto> [filters] |
native getstatus cycle (statusResponse cvars) |
Q3 also serves the MOTD on 27951 (text configurable via Q3M_MOTD).
Build
make build # bin/quake-masterserver (version from git describe)
make test # go test ./...
make test-race # go test -race ./...
make vet # go vet ./...
make fmt # gofmt -w .
make check # vet + race tests
make run # build and start
The binary is a single static Go executable with no runtime dependencies. UDP traffic needs outbound and inbound access to the configured master ports (for info probing, the master also dials out to the registered game servers).
Configuration
Everything is configured via the environment; a .env file in the working
directory is loaded automatically (ENV_FILE / --env-file to override).
env.example lists every variable. All games are enabled by default,
mirroring the original image.
| Variable | Default | Notes |
|---|---|---|
Q2M_ENABLED / Q2M_PORT |
true / 27900 |
Quake II master |
QWM_ENABLED / QWM_PORT |
true / 27000 |
QuakeWorld master |
H2M_ENABLED / H2M_PORT |
true / 28900 |
Heretic II master |
D3M_ENABLED / D3M_PORT |
true / 27650 |
Doom 3 master |
Q3M_ENABLED / Q3M_PORT |
true / 27950 |
Quake 3 master |
Q3M_MOTD_PORT / Q3M_MOTD |
27951 / welcome text |
Q3 MOTD port and text |
MS_BIND_ADDR |
0.0.0.0 |
UDP bind address (IPv4) |
MS_HEARTBEAT_TIMEOUT |
5m |
drop servers without a heartbeat after |
MS_SWEEP_INTERVAL |
15s |
reap dead registrations |
MS_MAX_SERVERS |
4096 |
server cap per game |
MS_INFO_TTL |
60s |
how often game servers are re-probed |
MS_INFO_PROBE_TIMEOUT |
2s |
per game server probe timeout |
MS_MAX_INFO_PROBES |
64 |
max servers probed per refresh round |
MS_PACKET_RATE / MS_PACKET_WINDOW |
100 / 10s |
per source IP rate limit |
MS_ENABLE_ADMIN |
false |
expose the remove_server tool |
MCP_ADDR |
127.0.0.1:8080 |
HTTP MCP listen address |
MCP_PATH |
/mcp |
must start with /, must not be /healthz |
MCP_STATELESS |
false |
disable MCP sessions |
MCP_AUTH_TOKEN |
– | bearer token; required for non-loopback MCP_ADDR |
LOG_LEVEL |
info |
debug, info, warn, error |
ENV_FILE |
.env |
alternative env file |
Flags (--addr, --path, --stateless, --log-level, --env-file,
--version) override the corresponding transport settings.
MCP tools
For every enabled game the server registers two read-only tools:
list_<game>_servers→{"game": "q3", "servers": ["10.0.0.1:27960", ...], "count": n}<game>_server_info→{"game": "q3", "servers": [{"addr", "name", "map", "players", "max_players", "protocol", "info": {...}}], "count": n}(optional inputaddrlimits the result to one server)
and, when MS_ENABLE_ADMIN=true:
remove_server→{"game", "addr", "removed"}
Example HTTP setup for a local MCP client:
url: http://127.0.0.1:8080/mcp
/healthz returns {"status":"ok"} without auth and is usable for probes.
Client library
The client packages at the module root are importable:
import (
"context"
"tangled.org/paulklumpp.tngl.sh/go-masterserver-client-mcp/client/q3"
)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Server list from the master (protocol 68 = Q3 1.32).
servers, err := q3.GetServers(ctx, "master.example.com:27950", 68)
for _, s := range servers {
fmt.Println(s.Addr()) // "10.0.0.1:27960"
}
// Info for one game server.
info, err := q3.Status(ctx, "10.0.0.1:27960")
fmt.Println(info.Name, info.Map, info.Clients)
Available packages and one-shot functions:
| Package | Functions |
|---|---|
client |
Dial, RoundTrip, Read, ReadUntilIdle, ParseCvars, DecodeAddrBlocks, ParseBackslashPairs |
client/q2 |
GetServers, Status, ParseServerList, ParsePrint |
client/q3 |
GetServers, Status, ParseServerList, ParseStatusResponse |
client/qw |
GetServers, Status, ParseServerList, ParseReply |
client/h2 |
GetServers, Status, ParseServerList, ParsePrint |
client/d3 |
GetServers, Status, ParseServerList, ParseInfoResponse, Protocol |
The master server itself reuses these packages for its probing, so the library is exercised by the main program.
Security
Memory safety against malicious clients
The UDP side is hardening-first:
- Per source IP rate limiting (not per
ip:port):MS_PACKET_RATEpackets perMS_PACKET_WINDOWper IP. Every incoming packet is checked before any processing, so floods neither grow state nor trigger replies (no reflection/amplification of inbound traffic). - Bounded tracking table: the rate limiter tracks at most 65536 source IPs. Once the table is full, unknown sources are dropped instead of allocated — an attacker cannot push memory usage up with spoofed addresses. Known sources keep working.
- Server caps:
MS_MAX_SERVERSper game (default 4096); registration attempts beyond the cap are ignored. - Packet size cap: payloads above 1400 bytes are dropped at the engine.
- Bounded outbound probing: info refresh probes at most
MS_MAX_INFO_PROBESservers per round with a per-probe timeout, so a hostile list of registrations cannot turn the master into an open prober. - Q3 requires the server to echo the master's challenge in its
statusResponse; mismatched info is ignored. - A second Q3 heartbeat within 2 seconds is a shutdown signal, exactly like the original.
Sender address spoofing over UDP
Short answer: no — over plain UDP the source address cannot be
verified, so a client can always claim a spoofed ip:port origin. This
is a property of UDP/IP, not of this implementation.
What this server does instead (mitigation, not prevention):
- Treats every packet as untrusted and validates it before acting: protocol header, packet size, rate limit, challenge echo (Q3).
- Limits every amplification path: the only replies to inbound packets are
the small list responses to
query/c/getservers, which are rate-limited per source IP like everything else. - Bounds every table keyed by (potentially spoofed) address, so a spoofing flood cannot grow memory (see above).
- Consequences for spoofing specifically: a client can register itself from a fake address (the master cannot tell), but it cannot register more servers than the per-game cap, cannot keep a fake registration alive without continuing to send from that address (heartbeats are required, and each one is rate-limited), and cannot use the master to amplify traffic toward third parties.
If you need real sender authentication, put the master behind a firewall that restricts the master ports to the subnets of the game servers, or run the games on a network where source IP spoofing is prevented at the switch/router level (e.g. anti-spoofing ACLs, or a single-subnet broadcast domain where the L2 address is pinned).
Architecture
┌──────────────────────────────────────────┐
game servers │ engine (per port) │
(heartbeat, list │ UDP socket ──▶ rate limit ──▶ Protocol │
requests) │ ▲ │ │
┌──────────────┤ │ (getstatus, getInfo, ...) ▼ │
│ │ └──────────────────────────────────┘
▼ │ game masters: internal/games/{q2,qw,h2,d3,q3}
┌─────────┐ │ in-memory registrations + cached server info
│ UDP │ │ ▲ │
└─────────┘ │ │ RefreshInfo (probe via client pkgs)│
│ │ │ │
▼ │ ▼ │
list/info requests │ client/{q2,qw,h2,d3,q3} (public) │
│ │ │
│ ▼ │
│ internal/mcp (tools per enabled game) │
│ │ │
│ ▼ │
│ internal/transport (streamable HTTP) │
└──────────────────────────────────────────┘
cmd/quake-masterserver— flags, env wiring, logger, signals.internal/config— environment and.envparsing, validation.internal/engine— one UDP socket per port, rate limiting, sweep loop, info refresh loop.internal/games/*— one package per game master protocol; each implementsmaster.Protocol.internal/master— theProtocolinterface, server records, rate limiter, the UDP prober used byRefreshInfo.internal/mcp— MCP server and the per-game tool handlers.internal/transport— streamable HTTP, bearer auth,/healthz.client/*— the public query library (also used by the master).
License
GNU Affero General Public License v3 — Copyright (C) 2026 Paul Klumpp.