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]>
72 lines
2.8 KiB
Python
Executable File
72 lines
2.8 KiB
Python
Executable File
#!/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))
|