From bc5bd09c1586b8906637a0a895f17db6822e8e37 Mon Sep 17 00:00:00 2001 From: esefxdz Date: Thu, 8 Oct 2026 01:39:38 +0300 Subject: [PATCH] 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 --- .env.example | 35 +++++++ .gitattributes | 1 + .gitignore | 15 +++ Dockerfile | 10 ++ LICENSE | 21 ++++ README.md | 93 ++++++++++++++++++ docker-compose.yml | 37 ++++++++ docs/ADMIN.md | 68 +++++++++++++ docs/HOW-IT-WORKS.md | 50 ++++++++++ docs/PLAYERS.md | 41 ++++++++ home/baseq3/scripts/lgvamp.factories | 137 +++++++++++++++++++++++++++ home/baseq3/server.cfg | 30 ++++++ home/minqlx-plugins/nowarmuptext.py | 32 +++++++ home/minqlx-plugins/welcome.py | 23 +++++ rcon | 7 ++ scripts/backup.sh | 16 ++++ scripts/daily-restart.sh | 16 ++++ scripts/env.sh | 5 + scripts/install-cron.sh | 17 ++++ scripts/watchdog.sh | 22 +++++ setup.sh | 122 ++++++++++++++++++++++++ tools/mkcfg.sh | 35 +++++++ tools/qlq.py | 71 ++++++++++++++ tools/qlrcon.py | 62 ++++++++++++ tools/test_qlq.py | 43 +++++++++ 25 files changed, 1009 insertions(+) create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 docs/ADMIN.md create mode 100644 docs/HOW-IT-WORKS.md create mode 100644 docs/PLAYERS.md create mode 100644 home/baseq3/scripts/lgvamp.factories create mode 100644 home/baseq3/server.cfg create mode 100644 home/minqlx-plugins/nowarmuptext.py create mode 100644 home/minqlx-plugins/welcome.py create mode 100755 rcon create mode 100755 scripts/backup.sh create mode 100755 scripts/daily-restart.sh create mode 100755 scripts/env.sh create mode 100755 scripts/install-cron.sh create mode 100755 scripts/watchdog.sh create mode 100755 setup.sh create mode 100755 tools/mkcfg.sh create mode 100755 tools/qlq.py create mode 100755 tools/qlrcon.py create mode 100644 tools/test_qlq.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..af2a608 --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# ql-lg-server settings. setup.sh copies this to .env (and asks for the important ones). +# After editing .env: docker compose up -d (recreates the container with the new values) +# No double quotes inside values. + +# Name shown in the server browser and the scoreboard (^1..^7 = Quake colors) +QL_HOSTNAME=LG Vampiric FFA + +# yes = listed in the in-game server browser (forward UDP QL_PORT on your router/firewall) +# no = unlisted, people join by address (LAN, VPN like Tailscale, or a known IP) +QL_LISTED=yes + +# Join password (empty = open server) +QL_PASSWORD= + +QL_MAXCLIENTS=16 + +# lgvamp LG only, 1:1 vampiric, no knockback, antilag (like x76 "Antilag LG Duel") +# lgvamp_nolag same without antilag (like x76 "LG Duel EU #3") +# lgvamp100 spawn with flat 100 HP instead of 200 (no health decay above 100) +QL_FACTORY=lgvamp +QL_MAP=thunderstruck + +# Shown to every player when they join. Empty = no message. +QL_WELCOME=^7Welcome! LG only, vampiric, no knockback. Pair up and duel, there is no match to ready up for. + +# Game port (UDP) and RCON port (TCP, bound to 127.0.0.1 only). Change both to run a second server. +QL_PORT=27960 +QL_RCON_PORT=28960 + +# Docker container name (unique per server on this machine) +QL_NAME=ql-lg + +# User/group the server runs as (owner of this folder). setup.sh fills these in. +QL_UID=1000 +QL_GID=1000 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7bc7945 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# per-machine settings and secrets +.env +secrets/ +# downloaded / built (setup.sh recreates them) +server/ +steamcmd-home/ +minqlx-build/ +# runtime +logs/ +backups/ +home/baseq3/* +!home/baseq3/server.cfg +!home/baseq3/scripts/ +home/minqlx.log* +__pycache__/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..27e82a2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +# Quake Live dedicated server runtime + steamcmd. Game files live in ./server (bind mount). +# libpython3.11 + python3-redis: minqlx (built by setup.sh into ./server) embeds Python 3.11. +FROM debian:bookworm-slim +RUN dpkg --add-architecture i386 && apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates curl lib32gcc-s1 lib32stdc++6 libc6-i386 \ + libstdc++6 python3 python3-zmq libpython3.11 python3-redis && \ + rm -rf /var/lib/apt/lists/* +RUN mkdir -p /opt/steamcmd && curl -sSL https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz | tar xz -C /opt/steamcmd && \ + chmod -R a+rwX /opt/steamcmd +WORKDIR /ql/server diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c55fe7f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 ql-lg-server contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..96d77a9 --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# ql-lg-server + +A **Quake Live lightning gun (LG) vampiric practice server** in Docker, set up with one command. + +Players spawn with the LG only. Every hit heals you by the damage you deal (1:1 vampiric), there's no knockback and no items, and the server stays in warmup forever. Nobody has to ready up, there's no countdown, and you never get kicked back to a scoreboard. People join, pair off and duel on thunderstruck. This is how the popular LG duel servers (x76) run, rebuilt from scratch and documented so you can host your own. + +- One command: `./setup.sh` downloads the game (SteamCMD), builds minqlx, generates an RCON password and starts the server. +- Public (listed in the in-game browser) or private (join by address, LAN/VPN): one setting. +- Three variants: antilag, no antilag, flat 100 HP. +- No warmup text on screen (small minqlx plugin), plus a welcome message. +- Recovers on its own: Docker restarts it after a crash, a watchdog restarts it if it hangs, and it restarts daily while nobody is playing. It also takes nightly backups. +- The RCON password never shows up in logs (Quake Live normally prints it there, see [How it works](docs/HOW-IT-WORKS.md)). + +## Quick start + +You need a Linux machine (a small VPS is fine: the server idles at ~3% of one core and ~50 MB RAM), [Docker with the compose plugin](https://docs.docker.com/engine/install/), `python3`, git, and ~2 GB of disk. + +```sh +git clone ql-lg-server +cd ql-lg-server +./setup.sh # asks: server name, listed or not, join password, port +./scripts/install-cron.sh # optional: watchdog, nightly backup, daily restart +``` + +Run it as a normal user in the `docker` group, not as root. Setup prints the `/connect` addresses when it's done. Players need Quake Live on Steam. + +**Public server:** forward **UDP 27960** (your `QL_PORT`) on your router or firewall to this machine. Listed servers appear in the in-game browser after a few minutes. + +## Settings + +Everything per-server is in `.env` (created from [`.env.example`](.env.example) by setup). After editing it, apply the change with `docker compose up -d`. + +| Setting | Default | | +|---|---|---| +| `QL_HOSTNAME` | `LG Vampiric FFA` | name in the browser (`^1`..`^7` colors work) | +| `QL_LISTED` | `yes` | `no` = unlisted, join by address only | +| `QL_PASSWORD` | empty | join password | +| `QL_MAXCLIENTS` | `16` | | +| `QL_FACTORY` | `lgvamp` | `lgvamp`, `lgvamp_nolag` or `lgvamp100`, see below | +| `QL_MAP` | `thunderstruck` | stock map; workshop maps need extra setup | +| `QL_WELCOME` | short rules text | sent to each player on join; empty = off | +| `QL_PORT` / `QL_RCON_PORT` | `27960` / `28960` | game UDP / RCON TCP (127.0.0.1 only) | +| `QL_NAME` | `ql-lg` | Docker container name | + +### Game modes (factories) + +Defined in [`home/baseq3/scripts/lgvamp.factories`](home/baseq3/scripts/lgvamp.factories): + +| Factory | What | Seen on real servers | +|---|---|---| +| `lgvamp` | LG only, 1:1 vampiric, no knockback, antilag up to 240 ms, spawn 200 HP | x76 "Antilag LG Duel" | +| `lgvamp_nolag` | same, antilag off (you have to lead your shots by your ping) | x76 "LG Duel EU #3" | +| `lgvamp100` | same as `lgvamp` but spawn with 100 HP | no, our variant: health above 100 decays ~1 HP/s, so with 200 HP spawns you slowly lose health even when you aren't taking damage | + +Shared values: `g_startingWeapons 33` (gauntlet + LG), infinite ammo, `g_knockback 0`, no items, no `/kill`, no votes, instant weapon switch, `dmflags 28` (no self/fall damage), permanent warmup (`sv_warmupReadyPercentage 2`). Every value and why: [docs/HOW-IT-WORKS.md](docs/HOW-IT-WORKS.md). + +## Running it + +```sh +./rcon status # who's on (./rcon alone = interactive console) +./rcon addbot hunter 5 # a target to shoot at; ./rcon kick allbots +./rcon say hello +python3 tools/qlq.py 127.0.0.1 27960 --status +docker compose restart # restart; docker compose stop / start +./setup.sh --update # update the game from Steam +``` + +Admins, bans, mutes, multiple servers, backups: [docs/ADMIN.md](docs/ADMIN.md). Share [docs/PLAYERS.md](docs/PLAYERS.md) with your players (joining, LG client settings). + +## Layout + +``` +setup.sh install / re-run / --update +.env.example settings template (.env is yours, not in git) +docker-compose.yml the container; Dockerfile = Debian + 32-bit libs + Python 3.11 + SteamCMD +rcon server console +home/baseq3/ server.cfg (shared), scripts/lgvamp.factories (game modes); + local.cfg, secrets.cfg, mappool_lg.txt are generated from .env on each start +home/minqlx-plugins/ nowarmuptext.py (hides warmup text), welcome.py +tools/ mkcfg.sh (.env -> cfg), qlrcon.py (RCON client), qlq.py (health check), tests +scripts/ watchdog.sh, daily-restart.sh, backup.sh, install-cron.sh +``` + +Not in git (setup creates them): `server/` (game files, ~1.1 GB), `minqlx-build/`, `steamcmd-home/`, `secrets/`, `logs/`, `backups/`. + +## Credits + +- [minqlx](https://github.com/MinoMino/minqlx) (GPL-3.0) by MinoMino: built from source by setup.sh, not included here. +- [x76.gg](https://x76.gg/) for the LG duel server format this copies (FFA in permanent warmup, vampiric, no knockback). +- [SEA LG Duels server](https://github.com/minhlong124/quake-lg-sea-server): the vampiric LG preset this started from. +- Quake Live is © id Software / Bethesda. The dedicated server is downloaded from Steam (app 349090); no game files are in this repo. + +License: MIT (this repo's configs and scripts). diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5b2711b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +# Quake Live LG vampiric server. Settings: .env (see .env.example). Setup: ./setup.sh +# Game: UDP ${QL_PORT} on all interfaces. RCON: ZMQ TCP 127.0.0.1:${QL_RCON_PORT} only. +# Started through minqlx (run_server_x64_minqlx.sh) for the plugins in home/minqlx-plugins. +services: + ql: + build: . + image: ql-lg-server:local + container_name: ${QL_NAME:-ql-lg} + user: "${QL_UID:-1000}:${QL_GID:-1000}" + network_mode: host + restart: unless-stopped + init: true # tini as PID 1: if the server dies the container exits and Docker restarts it + ulimits: + core: 0 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + stdin_open: true + tty: true + env_file: .env + environment: + - HOME=/home/steam + - TINI_KILL_PROCESS_GROUP=1 # stop/restart signals reach qzeroded through the sh pipe below + volumes: + - ./server:/ql/server + - ./home:/ql/home + - ./secrets:/ql/secrets:ro + - ./tools:/ql/tools:ro + - ./steamcmd-home:/home/steam + # 1. mkcfg.sh turns .env into home/baseq3/local.cfg (+ secrets.cfg, mappool_lg.txt). + # 2. The minqlx start script turns zmq stats on (TCP, all interfaces): +set zmq_stats_enable 0 turns it off. + # 3. qzeroded's CZMQ auth logger prints the RCON password in plain text on every rcon login + # ("zauth: ... password=..."): grep drops those lines before they reach docker logs. + # If qzeroded dies, grep sees EOF, the container exits and Docker restarts it. + command: ["/bin/sh", "-c", "/ql/tools/mkcfg.sh && /ql/server/run_server_x64_minqlx.sh +set zmq_stats_enable 0 +set fs_homepath /ql/home +set net_port $$QL_PORT +exec server.cfg 2>&1 | grep --line-buffered -v 'zauth:'"] diff --git a/docs/ADMIN.md b/docs/ADMIN.md new file mode 100644 index 0000000..74854d4 --- /dev/null +++ b/docs/ADMIN.md @@ -0,0 +1,68 @@ +# Running the server + +Everything below is run from the repo folder on the server machine. + +## Console (RCON) + +`./rcon ` runs one server command; `./rcon` alone opens a `ql>` prompt (Ctrl+D quits). It runs inside the container and reads `secrets/rcon-password.txt` itself, so you never type the password. RCON listens on 127.0.0.1 only, so nobody can reach it from outside the machine. + +| Command | | +|---|---| +| `status` | players with their slot number (`num`), ping, address (`bot` = bot) | +| `say ` | message to everyone | +| `addbot hunter 5` | add a bot (names: hunter, anarki, hossman, …; skill 1–5) | +| `kick allbots` | remove all bots (prints a harmless "Player allbots is not on the server") | +| `clientkick ` | kick a player | +| `mute ` / `unmute ` | chat mute | +| `tempban ` | kick + ban until the map changes | +| `ban ` / `unban ` | permanent ban (stored in `access.txt`) | +| `addadmin ` / `addmod ` | give a connected player admin / mod rights | +| `reload_access` | re-read `access.txt` | +| `map thunderstruck lgvamp` | reload the map (or switch factory: `lgvamp_nolag`, `lgvamp100`) | + +**Careful:** `cvarlist` prints the RCON password into the server log. `./rcon` hides it from you, but avoid running it anyway. + +## Admins and bans: `home/baseq3/access.txt` + +One line per player: their 64-bit Steam ID, a `|`, and `admin`, `mod` or `ban`: + +``` +76561198000000000|admin +76561198000000001|ban +``` + +Quake Live writes this file back from memory on every map load and on shutdown, so a hand edit can be overwritten. Either use the console commands above (`addadmin`, `ban`, `unban`), or edit the file and run `./rcon reload_access` right away. To unban someone who isn't connected, delete their line and run `reload_access`. + +## Settings changes + +- `.env` (name, password, listed, factory, welcome text, ports): edit it, then run `docker compose up -d`. +- Gameplay: edit `home/baseq3/scripts/lgvamp.factories` (must stay valid JSON: `python3 -m json.tool home/baseq3/scripts/lgvamp.factories`), then run `docker compose restart`. +- A cvar changed over RCON (e.g. `./rcon timelimit 1`) lasts until the next restart, map changes included. + +## Self-healing (`./scripts/install-cron.sh`) + +| What | When | Log | +|---|---|---| +| crash restart | Docker (`restart: unless-stopped`) | `docker inspect -f '{{.RestartCount}}' ql-lg` | +| hang watchdog: 3 failed checks in a row → restart | every 5 min | `logs/watchdog.log` | +| backup of `.env`, secrets, access.txt, cfgs, plugins (14 days) | 04:40 | `backups/` | +| daily restart, skipped while a human is on | 05:00 | `logs/watchdog.log` | + +`./scripts/install-cron.sh --remove` removes those cron lines. A `docker compose stop` is respected: the watchdog leaves a stopped server alone. + +## Updates + +- Game: `./setup.sh --update`, then `docker compose restart`. If a game update ever removes `server/run_server_x64_minqlx.sh`, plain `./setup.sh` copies it back. +- This repo: `git pull`, then `./setup.sh` (re-running it is safe: it keeps your `.env`, password and game files). + +## More than one server + +Clone the repo into a second folder. Before running `./setup.sh`, create `.env` there from `.env.example` with a different `QL_PORT`, `QL_RCON_PORT` and `QL_NAME` (e.g. `27961`, `28961`, `ql-lg-2`). Each folder downloads its own game files (~1.1 GB). Each server costs about 3% of a CPU core while idle. + +## Logs + +`docker logs --tail 50 ql-lg` (your `QL_NAME`). minqlx log: `home/minqlx.log`. Lines with the RCON password are filtered out before they reach `docker logs`, but treat logs as private anyway. + +## Without minqlx + +If minqlx ever breaks after a game update, change `run_server_x64_minqlx.sh` to `run_server_x64.sh` in `docker-compose.yml` and run `docker compose up -d`. Everything still works except that the warmup text ("The match will begin when more players join") comes back and there's no welcome message. diff --git a/docs/HOW-IT-WORKS.md b/docs/HOW-IT-WORKS.md new file mode 100644 index 0000000..bd6d9e9 --- /dev/null +++ b/docs/HOW-IT-WORKS.md @@ -0,0 +1,50 @@ +# How it works (and why) + +Notes for anyone changing this setup. Each point below was hit while building it. + +## Permanent warmup instead of a match + +Real LG duel servers (x76) report `PRE_GAME` (warmup) even with 9 players on: everyone plays FFA in warmup, which never ends. That's exactly what LG practice needs: no countdown, no score resets, no map reloads. + +- `sv_warmupReadyPercentage 2`: a match would need 200% of players ready, so F3 / `/readyup` can never start one. This cvar is locked, so it can only be set in the factory, not in server.cfg. +- `g_warmupReadyDelay 0`: no auto-start timer. + +Approaches that don't work: +- `g_doWarmup 0` (always in a match): QL **forfeits a running match when fewer than 2 players are on, even with `g_allowForfeit 0`**. With one player you get reload → countdown → a few seconds of play → forfeit, every 15–20 s. A permanent bot (`bot_minplayers 2`) hides that, but players find it weird. +- Auto-start (`sv_warmupReadyPercentage 0` + a delay): a countdown and score reset every time a second player joins. + +`fraglimit 50`, `timelimit 15`, `g_overtime 0` are x76's values; they only matter if a match ever starts. + +## The warmup text (minqlx plugin `nowarmuptext`) + +In warmup the client draws "The match will begin when more players join" / "Press F3 to ready yourself". No cvar hides it. The client draws it only while configstring `CS_WARMUP` (index 5) holds a time (warmup sets `\time\-1`). The plugin hooks `set_configstring` and sends an empty value for index 5 instead. The server itself stays in warmup. The first map is set up before plugins load, so the plugin also clears the value once when it loads. Check: `docker logs ql-lg | grep nowarmuptext` shows `CS_WARMUP=''`. + +minqlx is started with only our plugins (`qlx_plugins "nowarmuptext,welcome"`). Its default plugin list needs a Redis server, which this setup doesn't run. QL's built-in `access.txt` covers admins and bans without it. + +## Vampiric, measured + +`g_vampiricDamage 1` heals exactly 1:1. Measured with `g_debugVampiricDamage 1` and two bots: an LG hit does 6 damage and the attacker gains 6. When both players hit each other at the same time, neither health changes. Spawning with 200 HP (`g_startingHealthBonus 100`) means health above 100 decays ~1/s, which is where the "I'm losing health" feeling comes from. `lgvamp100` removes it. (The stock `_training` factory uses 0.75.) + +## Antilag + +`g_lagHax 1`, `g_lagHaxMs 240`, `g_lagHaxMsec 10`, `g_lagHaxHistory 12`: hits are checked against where the target was on the shooter's screen, for pings up to 240 ms. `lgvamp_nolag` sets `g_lagHax 0`. + +## The RCON password leak + +qzeroded's built-in ZMQ auth logger (CZMQ zauth) prints `zauth: ... password=` in plain text on every RCON login. The compose command pipes the server's output through `grep -v zauth:` so those lines never reach `docker logs`. `TINI_KILL_PROCESS_GROUP=1` makes stop and restart signals reach the server through that pipe. If the server dies, grep sees end-of-file, the container exits and Docker restarts it. + +Two more places the password shows up: `home/baseq3/zmqpass.txt` (QL writes it on every start) and the output of `cvarlist`. `tools/qlrcon.py` filters it out of anything it prints. + +## Unlisted servers answer no queries + +With `sv_master 0`, QL ignores `getstatus`, `getinfo` and Steam A2S queries (`sv_serverType` doesn't change that). So the health check (`tools/qlq.py`) uses the connect handshake: send `getchallenge` and the reply is `print "No Steam auth token."`. That works for listed and unlisted servers. Player info comes from RCON `status`, where bots have the address `bot`; that's how the daily restart can tell whether a human is on. + +## Small things + +- SteamCMD needs `+@sSteamCmdForcePlatformType linux`, or it fails with "Missing configuration". +- minqlx must be built against the same Python as the image (Debian bookworm, 3.11). setup.sh builds it in a bookworm container, pinned to a tested commit. +- The minqlx start script turns on `zmq_stats_enable` (TCP, all interfaces). The compose command turns it off again. +- A tied FFA never ends on the timelimit (it goes to overtime); irrelevant in permanent warmup. +- Cvars changed over RCON survive map changes until the container restarts. +- `home/baseq3/access.txt` is rewritten by QL on map load/shutdown (see ADMIN.md). +- Polling RCON from several clients at once while the server restarts once coincided with a crash. Poll from one client, slowly. diff --git a/docs/PLAYERS.md b/docs/PLAYERS.md new file mode 100644 index 0000000..ca10e9d --- /dev/null +++ b/docs/PLAYERS.md @@ -0,0 +1,41 @@ +# Playing on an LG vampiric server + +## Joining + +You need Quake Live (Steam). Open the console with the `~` key and type: + +``` +/connect :27960 +``` + +Your server admin gives you the address. If the server is listed, it also shows up in the in-game server browser; search for "lg" or "vampiric". + +## How it works + +- You spawn with the **lightning gun** (and gauntlet), infinite ammo and 200 HP (100 HP on `lgvamp100` servers). +- **Vampiric:** every point of damage you deal heals you by the same amount. Health above 100 slowly decays (~1 HP per second). +- **No knockback, no items, no fall or self damage.** +- The server **stays in warmup on purpose.** There's no match to ready up for (F3 does nothing) and no scoreboard reset. Pair up with someone and duel, and switch partners whenever you like. +- **Antilag** (most servers): your hits register where you saw the enemy, up to about 240 ms ping. On `lgvamp_nolag` servers you have to lead your shots by your ping. + +## Client settings people use for LG + +These are client-side; type them in the console, or put them in `baseq3/autoexec.cfg` so they stick (Quake Live rewrites `qzconfig.cfg`). Ranges are from the [x76 config guide](https://x76.gg/wiki/ql-startup/); the values are preferences, not rules. + +| Cvar | What it does | +|---|---| +| `cg_lightningStyle 1`–`5` | beam look (Settings → Weapons → Lightning Style) | +| `cg_lightningImpact 0` / `1` | sparks where the beam hits | +| `cg_impactSparks 0` / `1` | hit sparks on players | +| `cg_drawGun 0`–`2`, `cg_gunX/Y/Z` | gun model visibility/position (less of the screen covered) | +| `cg_fov` | field of view | +| `cg_drawInputCmds 1` | shows your key presses, useful for checking your strafing | +| `cg_enemyHeadColor` etc. | bright enemy colors, e.g. `0xFF0000FF` | +| `r_fullbright 1` | flat bright walls (easier to track the enemy) | + +**No-beam LG:** some players use a "No LG Beam" workshop add-on with `cg_lightningStyle 3`. That Steam workshop item has been removed, and servers here run `sv_pure 1`, so don't count on it working. + +## Practice tips + +- The [x76 strafe-aim guide](https://x76.gg/wiki/strafe-aim-guide/) covers the technique LG duels are about: keep the mouse steady, track by strafing with the keyboard and correct small amounts with the mouse. +- Practice alone: ask an admin for a bot (`addbot hunter 5`). Bots jump around a lot, which makes them a fair tracking target, but they don't fight well in this mode. diff --git a/home/baseq3/scripts/lgvamp.factories b/home/baseq3/scripts/lgvamp.factories new file mode 100644 index 0000000..9a95ad3 --- /dev/null +++ b/home/baseq3/scripts/lgvamp.factories @@ -0,0 +1,137 @@ +[ + { + "id": "lgvamp", + "title": "LG Vampiric FFA", + "author": "ql-lg-server", + "description": "LG only, 1:1 vampiric, no knockback, no items, antilag. Permanent warmup like the x76 LG duel servers: pair up and duel, ready-up can't start a match.", + "basegt": "ffa", + "tags": [ + "lg", + "aimtraining" + ], + "cvars": { + "g_startingWeapons": "33", + "g_infiniteAmmo": "1", + "g_startingHealth": "100", + "g_startingHealthBonus": "100", + "g_startingArmor": "0", + "g_vampiricDamage": "1", + "g_knockback": "0", + "g_weaponrespawn": "0", + "g_spawnItemAmmo": "0", + "g_spawnItemArmor": "0", + "g_spawnItemHealth": "0", + "g_spawnItemHoldable": "0", + "g_spawnItemPowerup": "0", + "g_spawnItemRune": "0", + "g_spawnItemWeapons": "0", + "g_allowKill": "0", + "g_allowVote": "0", + "g_allowVoteMidGame": "0", + "g_allowSpecVote": "0", + "g_voteFlags": "4095", + "pmove_WeaponRaiseTime": "0", + "pmove_WeaponDropTime": "0", + "sv_warmupReadyPercentage": "2", + "g_warmupReadyDelay": "0", + "fraglimit": "50", + "timelimit": "15", + "g_lagHax": "1", + "g_lagHaxMs": "240", + "g_lagHaxMsec": "10", + "g_lagHaxHistory": "12", + "dmflags": "28", + "g_overtime": "0" + } + }, + { + "id": "lgvamp_nolag", + "title": "LG Vampiric FFA (no antilag)", + "author": "ql-lg-server", + "description": "LG only, 1:1 vampiric, no knockback, no items, no antilag. Permanent warmup like the x76 LG duel servers: pair up and duel, ready-up can't start a match.", + "basegt": "ffa", + "tags": [ + "lg", + "aimtraining" + ], + "cvars": { + "g_startingWeapons": "33", + "g_infiniteAmmo": "1", + "g_startingHealth": "100", + "g_startingHealthBonus": "100", + "g_startingArmor": "0", + "g_vampiricDamage": "1", + "g_knockback": "0", + "g_weaponrespawn": "0", + "g_spawnItemAmmo": "0", + "g_spawnItemArmor": "0", + "g_spawnItemHealth": "0", + "g_spawnItemHoldable": "0", + "g_spawnItemPowerup": "0", + "g_spawnItemRune": "0", + "g_spawnItemWeapons": "0", + "g_allowKill": "0", + "g_allowVote": "0", + "g_allowVoteMidGame": "0", + "g_allowSpecVote": "0", + "g_voteFlags": "4095", + "pmove_WeaponRaiseTime": "0", + "pmove_WeaponDropTime": "0", + "sv_warmupReadyPercentage": "2", + "g_warmupReadyDelay": "0", + "fraglimit": "50", + "timelimit": "15", + "g_lagHax": "0", + "g_lagHaxMs": "240", + "g_lagHaxMsec": "10", + "g_lagHaxHistory": "12", + "dmflags": "28", + "g_overtime": "0" + } + }, + { + "id": "lgvamp100", + "title": "LG Vampiric FFA (100 HP)", + "author": "ql-lg-server", + "description": "LG only, 1:1 vampiric, no knockback, spawn with 100 HP (no decay), no items, antilag. Permanent warmup like the x76 LG duel servers: pair up and duel, ready-up can't start a match.", + "basegt": "ffa", + "tags": [ + "lg", + "aimtraining" + ], + "cvars": { + "g_startingWeapons": "33", + "g_infiniteAmmo": "1", + "g_startingHealth": "100", + "g_startingHealthBonus": "0", + "g_startingArmor": "0", + "g_vampiricDamage": "1", + "g_knockback": "0", + "g_weaponrespawn": "0", + "g_spawnItemAmmo": "0", + "g_spawnItemArmor": "0", + "g_spawnItemHealth": "0", + "g_spawnItemHoldable": "0", + "g_spawnItemPowerup": "0", + "g_spawnItemRune": "0", + "g_spawnItemWeapons": "0", + "g_allowKill": "0", + "g_allowVote": "0", + "g_allowVoteMidGame": "0", + "g_allowSpecVote": "0", + "g_voteFlags": "4095", + "pmove_WeaponRaiseTime": "0", + "pmove_WeaponDropTime": "0", + "sv_warmupReadyPercentage": "2", + "g_warmupReadyDelay": "0", + "fraglimit": "50", + "timelimit": "15", + "g_lagHax": "1", + "g_lagHaxMs": "240", + "g_lagHaxMsec": "10", + "g_lagHaxHistory": "12", + "dmflags": "28", + "g_overtime": "0" + } + } +] diff --git a/home/baseq3/server.cfg b/home/baseq3/server.cfg new file mode 100644 index 0000000..a6acd42 --- /dev/null +++ b/home/baseq3/server.cfg @@ -0,0 +1,30 @@ +// ql-lg-server. Per-server settings come from .env (local.cfg, generated on start). +// Gameplay lives in scripts/lgvamp.factories. See README.md and docs/HOW-IT-WORKS.md. +set sv_serverType "2" // internet server (also right for VPN players) +set sv_privateClients "0" +set sv_fps "40" +set sv_idleExit "0" +set net_strict "1" +set sv_floodprotect "10" +set g_floodprot_maxcount "10" +set g_floodprot_decay "1000" +set g_accessFile "access.txt" // admins, mods, bans (docs/ADMIN.md) +set sv_mapPoolFile "mappool_lg.txt" +set g_allowVote "0" + +// bots: none by default (permanent warmup needs none); rcon "addbot hunter 5", remove with "kick allbots" +set bot_enable "1" +set bot_minplayers "0" +set bot_nochat "1" + +// RCON over ZMQ, localhost only (port from .env, password from secrets/) +set zmq_rcon_enable "1" +set zmq_rcon_ip "127.0.0.1" +set zmq_stats_enable "0" + +// minqlx: only our plugins (the default minqlx plugin list needs a redis server) +set qlx_plugins "nowarmuptext,welcome" +set qlx_pluginsPath "/ql/home/minqlx-plugins" + +exec secrets.cfg +exec local.cfg // hostname, password, listing, max players, then: map diff --git a/home/minqlx-plugins/nowarmuptext.py b/home/minqlx-plugins/nowarmuptext.py new file mode 100644 index 0000000..0c2c393 --- /dev/null +++ b/home/minqlx-plugins/nowarmuptext.py @@ -0,0 +1,32 @@ +# hide the client's warmup text ("The match will begin when more players join", +# "Press F3 to ready yourself") while the server stays in permanent warmup (PRE_GAME). +# The client draws that text only while configstring CS_WARMUP (5) carries a nonzero +# "time" (warmup sets \time\-1), so we send clients an empty CS_WARMUP instead. +import minqlx + +CS_WARMUP = 5 + + +class nowarmuptext(minqlx.Plugin): + def __init__(self): + super().__init__() + self.add_hook("set_configstring", self.handle_set_configstring, priority=minqlx.PRI_HIGHEST) + self.add_hook("map", self.handle_map) + # The first map was set up before this plugin loaded: clear the value it stored. + minqlx.set_configstring(CS_WARMUP, "") + self.log_warmup("loaded") + + def handle_set_configstring(self, index, value): + if index == CS_WARMUP and value: + return "" + + def handle_map(self, mapname, factory): + self.log_warmup_later() + + @minqlx.delay(2) + def log_warmup_later(self): + self.log_warmup("map loaded") + + def log_warmup(self, when): + self.logger.info("nowarmuptext %s: CS_WARMUP=%r g_gameState=%s", when, + minqlx.get_configstring(CS_WARMUP), minqlx.get_cvar("g_gameState")) diff --git a/home/minqlx-plugins/welcome.py b/home/minqlx-plugins/welcome.py new file mode 100644 index 0000000..785d700 --- /dev/null +++ b/home/minqlx-plugins/welcome.py @@ -0,0 +1,23 @@ +# Welcome message: tells each player the text of qlx_welcome (QL_WELCOME in .env) a few seconds +# after they finish loading the map. Empty = nothing is sent. +import minqlx + + +class welcome(minqlx.Plugin): + def __init__(self): + super().__init__() + self.set_cvar_once("qlx_welcome", "") + self.add_hook("player_loaded", self.handle_player_loaded) + + def handle_player_loaded(self, player): + self.greet(player) + + @minqlx.delay(3) + def greet(self, player): + text = self.get_cvar("qlx_welcome") + if text: + try: + player.tell(text) + player.center_print(text) + except minqlx.NonexistentPlayerError: + pass # left before the message went out diff --git a/rcon b/rcon new file mode 100755 index 0000000..8473d9b --- /dev/null +++ b/rcon @@ -0,0 +1,7 @@ +#!/bin/sh +# Server console from the terminal: ./rcon status, ./rcon addbot hunter 5, or ./rcon for a prompt. +# Runs the client inside the container, so the RCON password never leaves this machine. +D=$(cd "$(dirname "$0")" && pwd) +NAME=$(sed -n 's/^QL_NAME=//p' "$D/.env" 2>/dev/null | tr -d '\r') +if [ -t 0 ]; then T=-it; else T=-i; fi +exec docker exec $T "${NAME:-ql-lg}" python3 /ql/tools/qlrcon.py "$@" diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100755 index 0000000..71eba52 --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Nightly backup of settings, secrets, admins/bans and plugins (not the game files: setup.sh +# re-downloads them). backups/ql-YYYY-MM-DD.tar.gz, 14 days kept. Also empties QL's log files +# once they pass 50 MB (QL appends to them forever). +. "$(dirname "$0")/env.sh" +set -e +umask 077 +mkdir -p backups +chmod 700 backups +tar czf backups/ql-$(date +%F).tar.gz .env secrets home/baseq3/access.txt \ + home/baseq3/server.cfg home/baseq3/scripts home/minqlx-plugins 2>/dev/null || true +find backups -name "ql-*.tar.gz" -mtime +14 -delete +for f in home/baseq3/*.log home/minqlx.log; do + if [ -f "$f" ] && [ "$(stat -c %s "$f")" -gt 52428800 ]; then : > "$f"; fi +done +exit 0 diff --git a/scripts/daily-restart.sh b/scripts/daily-restart.sh new file mode 100755 index 0000000..75e7c22 --- /dev/null +++ b/scripts/daily-restart.sh @@ -0,0 +1,16 @@ +#!/bin/sh +# Daily clean restart (cron). Skipped while a human is playing (bots have address 'bot'), and also +# skipped if the player count can't be read: better a missed restart than kicking someone. +. "$(dirname "$0")/env.sh" +LOG=logs/watchdog.log +[ "$(docker inspect -f '{{.State.Status}}' "$NAME" 2>/dev/null)" = running ] || exit 0 +H=$(python3 tools/qlq.py 127.0.0.1 "$QPORT" --humans 2>/dev/null) +case "$H" in + ''|*[!0-9]*) echo "$(date '+%F %T') daily restart skipped: player count unknown" >> $LOG; exit 0 ;; +esac +if [ "$H" -gt 0 ]; then + echo "$(date '+%F %T') daily restart skipped: $H player(s) on" >> $LOG + exit 0 +fi +echo "$(date '+%F %T') daily restart" >> $LOG +[ -n "$DRYRUN" ] || docker restart "$NAME" >/dev/null 2>&1 diff --git a/scripts/env.sh b/scripts/env.sh new file mode 100755 index 0000000..54632bf --- /dev/null +++ b/scripts/env.sh @@ -0,0 +1,5 @@ +# Sourced by the scripts in this folder: cd to the repo and read QL_NAME / QL_PORT from .env. +cd "$(dirname "$0")/.." || exit 1 +NAME=$(sed -n 's/^QL_NAME=//p' .env 2>/dev/null | tr -d '\r'); NAME=${NAME:-ql-lg} +QPORT=$(sed -n 's/^QL_PORT=//p' .env 2>/dev/null | tr -d '\r'); QPORT=${QPORT:-27960} +mkdir -p logs diff --git a/scripts/install-cron.sh b/scripts/install-cron.sh new file mode 100755 index 0000000..e36b14f --- /dev/null +++ b/scripts/install-cron.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# Adds the watchdog (every 5 min), backup (04:40) and daily restart (05:00) to your crontab. +# Safe to run again: replaces its own lines (tagged with this folder's path), leaves others alone. +# ./scripts/install-cron.sh --remove takes them out again. +D=$(cd "$(dirname "$0")/.." && pwd) +TAG="# ql-lg-server $D" +OLD=$(crontab -l 2>/dev/null | grep -vF "$TAG") +if [ "$1" = "--remove" ]; then + printf '%s\n' "$OLD" | crontab - && echo "removed cron lines for $D"; exit 0 +fi +{ + [ -n "$OLD" ] && printf '%s\n' "$OLD" + echo "*/5 * * * * $D/scripts/watchdog.sh $TAG" + echo "40 4 * * * $D/scripts/backup.sh $TAG" + echo "0 5 * * * $D/scripts/daily-restart.sh $TAG" +} | crontab - +echo "cron installed for $D:"; crontab -l | grep -F "$TAG" diff --git a/scripts/watchdog.sh b/scripts/watchdog.sh new file mode 100755 index 0000000..06b4467 --- /dev/null +++ b/scripts/watchdog.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# Hang watchdog (cron every 5 min). Docker already restarts the server when it crashes; this covers +# the other case: container "running" but the server not answering. Restarts it after 3 failed +# checks in a row (~15 min). Does nothing if the container was stopped on purpose. +# DRYRUN=1 logs without restarting; PORT=... overrides the port (testing). +. "$(dirname "$0")/env.sh" +STATE=logs/.watchdog-fails +LOG=logs/watchdog.log +PORT=${PORT:-$QPORT} +[ "$(docker inspect -f '{{.State.Status}}' "$NAME" 2>/dev/null)" = running ] || { rm -f $STATE; exit 0; } +if python3 tools/qlq.py 127.0.0.1 "$PORT" >/dev/null 2>&1; then + rm -f $STATE +else + n=$(( $(cat $STATE 2>/dev/null || echo 0) + 1 )) + echo $n > $STATE + echo "$(date '+%F %T') no answer ($n/3)" >> $LOG + if [ $n -ge 3 ]; then + echo "$(date '+%F %T') restarting $NAME" >> $LOG + [ -n "$DRYRUN" ] || docker restart "$NAME" >/dev/null 2>&1 + rm -f $STATE + fi +fi diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..cba86a7 --- /dev/null +++ b/setup.sh @@ -0,0 +1,122 @@ +#!/bin/sh +# ql-lg-server setup: run from the repo folder on a Linux machine with Docker. +# ./setup.sh first install (asks a few questions), or re-run safely any time +# ./setup.sh --yes no questions: use .env as it is (or the defaults) +# ./setup.sh --update also update the Quake Live game files from Steam +# Steps: check tools -> .env -> RCON password -> build image -> download QL (SteamCMD, ~1 GB) +# -> build minqlx -> start -> check the server answers. +set -e +cd "$(dirname "$0")" +YES=; UPDATE= +for a in "$@"; do + case "$a" in + --yes|-y) YES=1 ;; + --update) UPDATE=1 ;; + *) echo "unknown option: $a"; exit 1 ;; + esac +done +MINQLX_REPO=https://github.com/MinoMino/minqlx.git +MINQLX_COMMIT=fbdd915 # v0.5.3-15, the version this repo was tested with + +say() { printf '\n\033[1m== %s\033[0m\n' "$*"; } +die() { printf '\033[31merror:\033[0m %s\n' "$*" >&2; exit 1; } +setenv() { # setenv KEY VALUE: replace the KEY= line in .env + v=$(printf '%s' "$2" | sed 's/[\\&|]/\\&/g') + sed -i "s|^$1=.*|$1=$v|" .env +} +getenv() { sed -n "s/^$1=//p" .env | tr -d '\r'; } +ask() { # ask "question" default -> answer (default when --yes or no terminal) + if [ -n "$YES" ] || [ ! -t 0 ]; then printf '%s' "$2"; return; fi + printf '%s [%s]: ' "$1" "$2" >&2; read -r r; printf '%s' "${r:-$2}" +} + +say "Checking tools" +[ "$(id -u)" != 0 ] || die "don't run this as root: use a normal user in the 'docker' group" +command -v docker >/dev/null || die "docker is not installed (https://docs.docker.com/engine/install/)" +docker info >/dev/null 2>&1 || die "can't talk to docker: add yourself to the docker group (sudo usermod -aG docker \$USER, then log in again)" +docker compose version >/dev/null 2>&1 || die "'docker compose' (v2 plugin) is missing" +command -v python3 >/dev/null || die "python3 is missing (used by the health checks)" +echo "ok" + +say "Settings (.env)" +if [ ! -f .env ]; then + cp .env.example .env + setenv QL_HOSTNAME "$(ask 'Server name' "$(getenv QL_HOSTNAME)")" + l=$(ask 'List it in the in-game server browser? (yes/no)' yes) + case "$l" in y|Y|yes|YES) setenv QL_LISTED yes ;; *) setenv QL_LISTED no ;; esac + setenv QL_PASSWORD "$(ask 'Join password (empty = open server)' '')" + setenv QL_PORT "$(ask 'Game port (UDP)' "$(getenv QL_PORT)")" + echo "created .env (edit it any time, then: docker compose up -d)" +else + echo "using existing .env" +fi +setenv QL_UID "$(id -u)" +setenv QL_GID "$(id -g)" +PORT=$(getenv QL_PORT); RPORT=$(getenv QL_RCON_PORT); NAME=$(getenv QL_NAME) +[ "$PORT" != "$RPORT" ] || die "QL_PORT and QL_RCON_PORT must differ" +RUNNING=$(docker inspect -f '{{.State.Status}}' "$NAME" 2>/dev/null || true) +if [ "$RUNNING" != running ] && command -v ss >/dev/null; then + ! ss -lun | grep -q ":$PORT " || die "UDP port $PORT is already in use: set another QL_PORT in .env" + ! ss -ltn | grep -q ":$RPORT " || die "TCP port $RPORT is already in use: set another QL_RCON_PORT in .env" +fi +echo "server '$(getenv QL_HOSTNAME)', container $NAME, UDP $PORT, listed: $(getenv QL_LISTED)" + +say "RCON password" +mkdir -p secrets server steamcmd-home logs minqlx-build +chmod 700 secrets +if [ ! -s secrets/rcon-password.txt ]; then + (umask 077; head -c 64 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 24 > secrets/rcon-password.txt) + echo "generated secrets/rcon-password.txt (you never need to type it: ./rcon uses it)" +else + echo "keeping secrets/rcon-password.txt" +fi +chmod +x rcon tools/*.sh scripts/*.sh + +say "Building the Docker image" +docker compose build -q + +if [ ! -x server/qzeroded.x64 ] || [ -n "$UPDATE" ]; then + say "Downloading Quake Live dedicated server (SteamCMD app 349090, ~1 GB)" + docker compose run --rm --no-deps --entrypoint /opt/steamcmd/steamcmd.sh ql \ + +@sSteamCmdForcePlatformType linux +force_install_dir /ql/server \ + +login anonymous +app_update 349090 validate +quit + [ -x server/qzeroded.x64 ] || die "download failed (server/qzeroded.x64 missing): run ./setup.sh again" +else + echo "game files present (./setup.sh --update to update them)" +fi + +if [ ! -f minqlx-build/minqlx.x64.so ]; then + say "Building minqlx ($MINQLX_COMMIT)" + docker run --rm -v "$PWD/minqlx-build:/out" debian:bookworm-slim sh -c " + apt-get update -qq && apt-get install -y -qq build-essential python3-dev git ca-certificates >/dev/null && + git clone -q $MINQLX_REPO /src && cd /src && git checkout -q $MINQLX_COMMIT && make >/dev/null && + cp bin/minqlx.x64.so bin/minqlx.zip bin/run_server_x64_minqlx.sh /out/ && chown -R $(id -u):$(id -g) /out" + [ -f minqlx-build/minqlx.x64.so ] || die "minqlx build failed" +fi +cp minqlx-build/minqlx.x64.so minqlx-build/minqlx.zip minqlx-build/run_server_x64_minqlx.sh server/ +chmod +x server/run_server_x64_minqlx.sh +echo "minqlx installed into server/" + +say "Starting" +docker compose up -d +printf 'waiting for the server to answer' +i=0 +until python3 tools/qlq.py 127.0.0.1 "$PORT" >/dev/null 2>&1; do + i=$((i+1)); [ $i -lt 30 ] || { echo; die "no answer after 90 s: look at 'docker logs --tail 50 $NAME'"; } + printf '.'; sleep 3 +done +echo " up" +python3 tools/qlq.py 127.0.0.1 "$PORT" --status || true + +say "Done" +IPS=$(hostname -I 2>/dev/null | tr ' ' '\n' | grep -v ':' | grep -v '^172\.' | head -3 | tr '\n' ' ') +echo "Join from Quake Live (console, ~ key):" +for ip in $IPS; do echo " /connect $ip:$PORT"; done +if [ "$(getenv QL_LISTED)" = yes ]; then + echo "From the internet: forward UDP $PORT to this machine, then /connect :$PORT" + echo "(listed servers show up in the in-game browser after a few minutes)" +fi +echo +echo "Console: ./rcon status ./rcon addbot hunter 5 ./rcon kick allbots" +echo "Auto-restart: ./scripts/install-cron.sh (watchdog, nightly backup, daily restart)" +echo "Docs: README.md, docs/ADMIN.md, docs/PLAYERS.md" diff --git a/tools/mkcfg.sh b/tools/mkcfg.sh new file mode 100755 index 0000000..b43cfd5 --- /dev/null +++ b/tools/mkcfg.sh @@ -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 < $B/mappool_lg.txt +echo "mkcfg: $MAP $FACTORY, listed=$MASTER, port ${QL_PORT}" diff --git a/tools/qlq.py b/tools/qlq.py new file mode 100755 index 0000000..2323df9 --- /dev/null +++ b/tools/qlq.py @@ -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 alive check from anywhere (exit 0 = answered, 1 = no answer) + python3 tools/qlq.py --humans (server) number of human players (bots have address 'bot') + python3 tools/qlq.py --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)) diff --git a/tools/qlrcon.py b/tools/qlrcon.py new file mode 100755 index 0000000..bf89290 --- /dev/null +++ b/tools/qlrcon.py @@ -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)) diff --git a/tools/test_qlq.py b/tools/test_qlq.py new file mode 100644 index 0000000..7e2091d --- /dev/null +++ b/tools/test_qlq.py @@ -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()