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:
commit
bc5bd09c15
25 files changed
+1009
No files matched your search
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
+15
@@ -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__/
|
||||||
+10
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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 <this repo> 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).
|
||||||
@@ -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:'"]
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Running the server
|
||||||
|
|
||||||
|
Everything below is run from the repo folder on the server machine.
|
||||||
|
|
||||||
|
## Console (RCON)
|
||||||
|
|
||||||
|
`./rcon <command>` 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 <text>` | 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 <num>` | kick a player |
|
||||||
|
| `mute <num>` / `unmute <num>` | chat mute |
|
||||||
|
| `tempban <num>` | kick + ban until the map changes |
|
||||||
|
| `ban <num>` / `unban <num>` | permanent ban (stored in `access.txt`) |
|
||||||
|
| `addadmin <num>` / `addmod <num>` | 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.
|
||||||
@@ -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=<your rcon 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.
|
||||||
@@ -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 <server address>: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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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 <map> <factory>
|
||||||
@@ -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"))
|
||||||
@@ -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
|
||||||
@@ -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 "$@"
|
||||||
Executable
+16
@@ -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
|
||||||
Executable
+16
@@ -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
|
||||||
Executable
+5
@@ -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
|
||||||
Executable
+17
@@ -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"
|
||||||
Executable
+22
@@ -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
|
||||||
@@ -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 <your public IP>:$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"
|
||||||
Executable
+35
@@ -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
@@ -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))
|
||||||
Executable
+62
@@ -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))
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user