ql-lg-server: Quake Live LG vampiric practice server in Docker

One-command setup (SteamCMD + minqlx build), .env settings with a
listed/unlisted switch, three factories (lgvamp, lgvamp_nolag, lgvamp100),
warmup-text and welcome minqlx plugins, watchdog/backup/daily-restart,
docs for admins and players.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
esefandClaude Opus 5.5 committed 2026-10-08 01:39:43 +03:00
commit bc5bd09c15
25 files changed
+1009

No files matched your search

Executable
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
# Runs inside the container before the server starts: turns the .env settings (passed in as
# environment variables) into home/baseq3/local.cfg, secrets.cfg and mappool_lg.txt.
# server.cfg execs secrets.cfg and local.cfg; local.cfg ends with the map command.
set -e
B=/ql/home/baseq3
clean() { printf '%s' "$1" | tr -d '"\r'; }
PW=$(tr -d '\r\n' < /ql/secrets/rcon-password.txt)
[ -n "$PW" ] || { echo "mkcfg: secrets/rcon-password.txt is empty (run setup.sh)" >&2; exit 1; }
case "$QL_LISTED" in
yes|1|true) MASTER=1 ;;
*) MASTER=0 ;;
esac
FACTORY=$(clean "${QL_FACTORY:-lgvamp}")
MAP=$(clean "${QL_MAP:-thunderstruck}")
case "$FACTORY" in lgvamp_nolag) TAGS="lg,vampiric,ffa" ;; *) TAGS="lg,vampiric,ffa,antilag" ;; esac
umask 077
printf 'set zmq_rcon_password "%s"\n' "$(clean "$PW")" > $B/secrets.cfg
umask 022
cat > $B/local.cfg <<EOF
// Generated from .env by tools/mkcfg.sh on every start. Edit .env, not this file.
set sv_hostname "$(clean "${QL_HOSTNAME:-LG Vampiric FFA}")"
set sv_tags "$TAGS"
set sv_master "$MASTER"
set g_password "$(clean "$QL_PASSWORD")"
set sv_maxClients "$(clean "${QL_MAXCLIENTS:-16}")"
set zmq_rcon_port "$(clean "${QL_RCON_PORT:-28960}")"
set qlx_welcome "$(clean "$QL_WELCOME")"
map $MAP $FACTORY
EOF
echo "$MAP|$FACTORY" > $B/mappool_lg.txt
echo "mkcfg: $MAP $FACTORY, listed=$MASTER, port ${QL_PORT}"
Executable
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Quake Live server checks. With sv_master 0 (not browser-listed) QL ignores getstatus/getinfo/A2S,
so liveness uses the connect handshake ('getchallenge' -> 'No Steam auth token.') and player info
comes from RCON 'status' (only on the server machine, through ../rcon).
python tools/qlq.py <host> <port> alive check from anywhere (exit 0 = answered, 1 = no answer)
python3 tools/qlq.py <host> <port> --humans (server) number of human players (bots have address 'bot')
python3 tools/qlq.py <host> <port> --status (server) alive + map, factory, players
"""
import os, re, socket, subprocess, sys
H = b"\xff\xff\xff\xff"
RCON = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "rcon")
# num score ping name lastmsg address qport rate steamid (ping may be CNCT/ZMBI while connecting)
ROW = re.compile(r"^\s*\d+\s+(-?\d+)\s+(\d+|CNCT|ZMBI)\s+(.*?)\s+\d+\s+(\S+)\s+\d+\s+\d+\s+\d+\s*$")
def is_alive_reply(data):
return data.startswith(H) and len(data) > 4
def alive(host, port, timeout=3, tries=3):
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.settimeout(timeout)
for _ in range(tries):
try:
s.sendto(H + b"getchallenge\n", (host, port))
if is_alive_reply(s.recv(65535)):
return True
except OSError:
continue
return False
def parse_rcon_status(text):
"""-> (mapname, [(score, ping, name, address)]) or None if this is not a status reply."""
m = re.search(r"^map: (\S+)", text, re.M)
if not m:
return None
players = []
for l in text.splitlines():
r = ROW.match(l)
if r:
players.append((int(r.group(1)), r.group(2), r.group(3), r.group(4)))
return m.group(1), players
def humans(players):
return sum(1 for p in players if p[3] != "bot")
def rcon(cmd):
return subprocess.run([RCON, cmd], capture_output=True, text=True, timeout=20).stdout
def main(argv):
host, port = argv[1], int(argv[2])
if "--humans" in argv:
st = parse_rcon_status(rcon("status"))
if st is None:
print("?"); return 1
print(humans(st[1])); return 0
if not alive(host, port):
print(f"no answer from {host}:{port}"); return 1
if "--status" in argv:
st = parse_rcon_status(rcon("status"))
fac = re.search(r'"g_factory" is:"([^"]*)"', rcon("g_factory"))
if st is None:
print(f"alive {host}:{port} | rcon: no status"); return 0
print(f"alive {host}:{port} | map {st[0]} | factory {fac.group(1) if fac else '?'}"
f" | players {len(st[1])} (humans {humans(st[1])})")
for p in st[1]:
print(f" {p[2]} score {p[0]} ping {p[1]} {'bot' if p[3] == 'bot' else 'human'}")
return 0
print(f"alive {host}:{port}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
"""Quake Live RCON over ZMQ. Runs inside the container (through ./rcon), so the password never
leaves the server machine.
./rcon status one command
./rcon interactive prompt (Ctrl+D or 'exit' to quit)
Protocol (id's zmq_rcon.py): DEALER socket, ZMQ PLAIN auth user 'rcon', zap_domain 'rcon',
first message 'register', then each message is a console command; console output comes back.
"""
import os, re, sys, uuid
import zmq
PW = open("/ql/secrets/rcon-password.txt").read().strip()
HOST = "tcp://127.0.0.1:" + os.environ.get("QL_RCON_PORT", "28960")
COLOR = re.compile(r"\^\d")
def connect():
s = zmq.Context.instance().socket(zmq.DEALER)
s.plain_username = b"rcon"
s.plain_password = PW.encode()
s.zap_domain = b"rcon"
s.setsockopt(zmq.IDENTITY, uuid.uuid4().hex.encode())
s.setsockopt(zmq.LINGER, 0)
s.connect(HOST)
s.send(b"register")
return s
def drain(s, first_wait, quiet=0.6):
out, wait = [], first_wait
while s.poll(int(wait * 1000)):
out.append(s.recv().decode(errors="replace"))
wait = quiet
return "".join(out)
def clean(text):
# never show the password, whatever the server echoes
lines = [COLOR.sub("", l) for l in text.splitlines()
if "zmq_rcon_password" not in l and PW not in l]
return "\n".join(lines).strip()
def run(s, cmd):
drain(s, first_wait=0.3, quiet=0.2) # drop console chatter that arrived in between
s.send(cmd.encode())
out = clean(drain(s, first_wait=3.0))
return out or "(no output: ok, or server down / map loading / wrong password)"
def main(argv):
s = connect()
if len(argv) > 1:
print(run(s, " ".join(argv[1:]))); return 0
print("Quake Live rcon - server commands (status, addbot hunter 5, kick allbots, map thunderstruck lgvamp, say hi). Ctrl+D to quit.")
while True:
try:
line = input("ql> ").strip()
except (EOFError, KeyboardInterrupt):
print(); return 0
if line in ("exit", "quit"):
return 0
if line:
print(run(s, line))
if __name__ == "__main__":
sys.exit(main(sys.argv))
+43
View File
@@ -0,0 +1,43 @@
import unittest
from qlq import is_alive_reply, parse_rcon_status, humans
# QL with sv_master 0 ignores getstatus/getinfo/A2S; it does answer getchallenge (real reply below).
ALIVE = b"\xff\xff\xff\xffprint\nNo Steam auth token.\n"
STATUS = """zmq RCON command from 3163ba8dd0fa4fab8164165fc9194153: status
map: thunderstruck
num score ping name lastmsg address qport rate steamid
--- ----- ---- --------------- ------- --------------------- ----- ----- -----------------
0 12 0 Hunter 0 bot 0 16384 0
1 7 48 player 0 100.64.1.2:27960 354 25000 76561198000000000
"""
class Alive(unittest.TestCase):
def test_challenge_reply_is_alive(self):
self.assertTrue(is_alive_reply(ALIVE))
def test_garbage_is_not_alive(self):
self.assertFalse(is_alive_reply(b"hello"))
self.assertFalse(is_alive_reply(b""))
class RconStatus(unittest.TestCase):
def test_map_and_players(self):
mapname, players = parse_rcon_status(STATUS)
self.assertEqual(mapname, "thunderstruck")
self.assertEqual([p[2] for p in players], ["Hunter", "player"])
def test_bots_are_not_humans(self):
_, players = parse_rcon_status(STATUS)
self.assertEqual(humans(players), 1)
def test_empty_server(self):
mapname, players = parse_rcon_status(STATUS.split(" 0 12")[0])
self.assertEqual(mapname, "thunderstruck")
self.assertEqual(players, [])
self.assertEqual(humans(players), 0)
def test_no_rcon_output_is_none(self):
self.assertIsNone(parse_rcon_status("(no output: ok, or server down / map loading / wrong password)"))
if __name__ == "__main__":
unittest.main()