Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .containerignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,10 @@
!README.md
justfile
quadlet
docs
docs/*
!docs/cstrike/
docs/cstrike/*
!docs/cstrike/maps/
docs/cstrike/maps/*
!docs/cstrike/maps/*.bsp
!docs/cstrike/maps/*.nav
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,11 @@ PORT=27015
# Enable ZBots (1=on, 0=off)
BOTS=1

# Map rotation file
MAPCYCLE=mapcycle.txt

# Public server mode (0=public, 1=LAN-only)
LAN_MODE=0

# Disable master-server registration (1=on, 0=off)
NOMASTER=0
2 changes: 1 addition & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ just check # both
## Architecture & Conventions

- **Multi-stage build**: builder stage downloads and assembles everything, runtime stage has only i386 libs
- **FIFO control**: entrypoint.sh creates `/tmp/hlds-input` named pipe for sending commands to HLDS
- **FIFO control**: entrypoint.sh creates `/hlds/.runtime/hlds-input` named pipe for sending commands to HLDS
- **Graceful shutdown**: traps SIGTERM/SIGINT, announces countdown, sends `quit` via FIFO
- **Quadlet**: `scoutzknivez.container` for systemd integration (auto-start, restart on-failure)
- **Pinned versions**: all component versions are `ARG` in Containerfile
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,8 @@ jobs:
version: v0.11.0
scandir: .
additional_files: entrypoint.sh install.sh

- name: Run entrypoint regression tests
run: |
bash tests/entrypoint_test.sh
bash tests/compose_test.sh
4 changes: 4 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,23 @@ on:
branches: [main]
paths:
- 'Containerfile'
- '.containerignore'
- 'entrypoint.sh'
- 'config/**'
- 'plugins/**'
- 'maps/**'
- 'docs/cstrike/maps/**'
- '.github/workflows/docker.yml'
pull_request:
branches: [main]
paths:
- 'Containerfile'
- '.containerignore'
- 'entrypoint.sh'
- 'config/**'
- 'plugins/**'
- 'maps/**'
- 'docs/cstrike/maps/**'
- '.github/workflows/docker.yml'
workflow_dispatch:

Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/friday-dust2.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ on:
- cron: "0 8 * * 5"
workflow_dispatch:

permissions:
contents: read

jobs:
dust2-announcement:
name: Friday Dust 2 Announcement
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ jobs:
cache-to: type=gha,mode=max

- name: Run Trivy vulnerability scanner
id: trivy
uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly
Expand All @@ -59,7 +60,7 @@ jobs:

- name: Upload Trivy results to GitHub Security
uses: github/codeql-action/upload-sarif@v4
if: always()
if: always() && steps.trivy.outcome == 'success'
with:
sarif_file: 'trivy-results.sarif'

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ quadlet/
- Minimal image size (~25-30MB for slim variant)
- glibc compatibility (alternatives like Alpine's musl cause HLDS issues)
- Alternatives rejected: Bookworm (shorter support window), Ubuntu (larger footprint), Alpine (glibc incompatibility)
- **FIFO-based server control**: `entrypoint.sh` creates a named pipe (`/tmp/hlds-input`) for sending commands to HLDS (say, quit, rcon). The `script` utility provides a PTY so HLDS doesn't block on stdin.
- **FIFO-based server control**: `entrypoint.sh` creates a private named pipe (`/hlds/.runtime/hlds-input`) for sending commands to HLDS (say, quit, rcon). The `script` utility provides a PTY so HLDS doesn't block on stdin.
- **Graceful shutdown**: Traps SIGTERM/SIGINT, announces countdown in-game (30s → 10s → 5s → 2s → 1s), then sends `quit` via FIFO.
- **Quadlet for systemd**: `scoutzknivez.container` unit file enables auto-start, crash recovery (restart on-failure), and resource limits (512MB RAM, 2 CPUs).

Expand Down
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ just up-d # build and start server (detached)
just down # stop server
just logs # follow server logs
just shell # exec into running container
just check # run all linters (hadolint + shellcheck)
just test # run entrypoint regression tests
just check # run all linters and tests
just clean # remove built images
```

Expand Down
16 changes: 7 additions & 9 deletions Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,6 @@ ARG AMXMODX_BUILD

ENV DEBIAN_FRONTEND=noninteractive

SHELL ["/bin/bash", "-o", "pipefail", "-c"]

# hadolint ignore=DL3008
RUN dpkg --add-architecture i386 && \
apt-get update && \
Expand All @@ -53,7 +51,9 @@ RUN dpkg --add-architecture i386 && \
# Install SteamCMD
RUN mkdir -p /opt/steamcmd && \
curl -fsSL https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz \
| tar -xz -C /opt/steamcmd
-o /opt/steamcmd/steamcmd_linux.tar.gz && \
tar -xzf /opt/steamcmd/steamcmd_linux.tar.gz -C /opt/steamcmd && \
rm -f /opt/steamcmd/steamcmd_linux.tar.gz

# Download HLDS (app 90, steam_legacy beta)
# Run app_update multiple times — app 90 has a known bug where it doesn't
Expand Down Expand Up @@ -118,9 +118,9 @@ RUN curl -fsSL "https://github.com/rehlds/ReAPI/releases/download/${REAPI_VERSIO
cstrike/addons/amxmodx/scripting/include/ 2>/dev/null || true && \
rm -rf /tmp/reapi /tmp/reapi.zip

# Copy custom maps
COPY maps/*.bsp cstrike/maps/
COPY maps/*.nav cstrike/maps/
# Copy bundled default maps, then overlay any local custom maps.
COPY docs/cstrike/maps/*.bsp docs/cstrike/maps/*.nav cstrike/maps/
COPY maps/ cstrike/maps/

# Compile custom AMX Mod X plugins
COPY plugins/amxmodx/scripting/*.sma cstrike/addons/amxmodx/scripting/
Expand All @@ -137,7 +137,7 @@ COPY plugins/amxmodx/AQS.ini cstrike/addons/amxmodx/configs/AQS.ini
COPY sound/quake/ cstrike/sound/quake/

# --- CZ Bots (ZBot profiles + sounds from ReGameDLL_CS) ---
RUN curl -fsSL "https://raw.githubusercontent.com/rehlds/ReGameDLL_CS/refs/heads/master/regamedll/extra/zBot/bot_profiles.zip" \
RUN curl -fsSL "https://raw.githubusercontent.com/rehlds/ReGameDLL_CS/${REGAMEDLL_VERSION}/regamedll/extra/zBot/bot_profiles.zip" \
-o /tmp/bot_profiles.zip && \
unzip -o /tmp/bot_profiles.zip -d . && \
rm -f /tmp/bot_profiles.zip
Expand All @@ -163,8 +163,6 @@ FROM debian:trixie-slim@sha256:3a39a0592364683e6bab97937b72cad5a8fa6dcbbee90edb3

ENV DEBIAN_FRONTEND=noninteractive

SHELL ["/bin/bash", "-o", "pipefail", "-c"]

# Prevent docs/man/locale from being installed (smaller image), but keep licenses
RUN printf 'path-exclude=/usr/share/doc/*\npath-include=/usr/share/doc/*/copyright\npath-include=/usr/share/doc/*/changelog.Debian*\npath-include=/usr/share/doc/*/LICENSE\npath-exclude=/usr/share/man/*\npath-exclude=/usr/share/locale/*\npath-exclude=/usr/share/bug/*\npath-exclude=/usr/share/lintian/*\npath-exclude=/usr/share/mime/*\npath-exclude=/usr/share/info/*\n' \
> /etc/dpkg/dpkg.cfg.d/excludes
Expand Down
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ If you need custom maps or configurations:

Place `.bsp` files into the `maps/` directory. They get baked into the image at build time.

Maps are not included in the repo due to file size. You can find them on sites like [GameBanana](https://gamebanana.com/mods/cats/5568) or [17buddies](https://www.17buddies.rocks/).
The default ScoutzKnivez maps are bundled from `docs/cstrike/maps/`. Add custom
maps to `maps/`; they overlay the bundled maps during the image build.

#### 2. Build and run

Expand Down Expand Up @@ -254,6 +255,9 @@ This copies files from `sound/quake/` and `maps/` into `docs/cstrike/` with `.bz
| `MAPCYCLE` | `mapcycle.txt` | Mapcycle file to use (e.g. `mapcycle-dust2.txt`, `mapcycle-nipper.txt`) |
| `LAN_MODE` | `0` | LAN-only mode (1) or public server mode (0) |

Startup rejects invalid booleans, out-of-range ports/player counts, and unsafe
map or mapcycle filenames instead of passing unchecked values to HLDS.

## Bots

Bots are enabled by default using ReGameDLL_CS's built-in ZBot support — no additional plugins or downloads required.
Expand Down Expand Up @@ -357,6 +361,8 @@ The Quadlet is configured with `WantedBy=default.target`, so the server starts a
- **System user with nologin shell** — HLDS runs as a system user (`-r`) with `/usr/sbin/nologin`, preventing interactive login
- **Setuid/setgid bits stripped** — all setuid/setgid bits removed from the runtime image
- **Resource limits** — `MemoryMax=512M`, `CPUQuota=200%` (2 cores max)
- **Runtime health check** — Compose and Quadlet verify that `hlds_linux` is
running, independent of image-format health-check metadata

### HLDS Anti-Abuse

Expand Down Expand Up @@ -412,6 +418,9 @@ sudo cpupower frequency-set -g performance
- Keep `/etc/systemd/system` and user Quadlet units under configuration management.
- Review image updates before deploying; retain the previous digest for rollback.
- Back up `${HOME}/.config/cs-server/`, especially `server.cfg` and map cycles.
- Restore by stopping the service, replacing that directory from backup with
owner-only permissions (`chmod 700` on the directory and `chmod 600` on its
files), then restarting the service.
- Configure host firewall rules for UDP/TCP `27015` and monitor the service with systemd/journald.
- Set journald or Docker log retention limits appropriate for the host.

Expand Down
7 changes: 7 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ services:
MAP: ${MAP:-scoutzknivez}
PORT: ${PORT:-27015}
BOTS: ${BOTS:-1}
NOMASTER: ${NOMASTER:-0}
MAPCYCLE: ${MAPCYCLE:-mapcycle.txt}
LAN_MODE: ${LAN_MODE:-0}
sysctls:
Expand All @@ -31,3 +32,9 @@ services:
cpus: 2.0
stop_grace_period: 50s
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "grep -qs hlds_linux /proc/[0-9]*/comm || exit 1"]
interval: 30s
timeout: 5s
start_period: 60s
retries: 3
113 changes: 89 additions & 24 deletions entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/bin/bash
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: 2026 Kevin T. Coughlin
# shellcheck disable=SC2329 # Signal/EXIT traps invoke these functions indirectly.
set -euo pipefail

MAP="${MAP:-scoutzknivez}"
Expand All @@ -11,27 +12,72 @@ NOMASTER="${NOMASTER:-0}"
MAPCYCLE="${MAPCYCLE:-mapcycle.txt}"
LAN_MODE="${LAN_MODE:-0}"

if [[ ! "${LAN_MODE}" =~ ^[01]$ ]]; then
echo "[entrypoint] LAN_MODE must be 0 or 1" >&2
exit 1
fi
validate_boolean() {
local name="$1" value="$2"
if [[ ! "${value}" =~ ^[01]$ ]]; then
echo "[entrypoint] ${name} must be 0 or 1" >&2
exit 1
fi
}

validate_integer() {
local name="$1" value="$2" minimum="$3" maximum="$4"
local normalized
local LC_ALL=C

if [[ ! "${value}" =~ ^[0-9]+$ ]]; then
echo "[entrypoint] ${name} must be an integer from ${minimum} to ${maximum}" >&2
exit 1
fi

normalized="${value#"${value%%[!0]*}"}"
normalized="${normalized:-0}"

if [[ "${#normalized}" -lt "${#minimum}" ]] ||
[[ "${#normalized}" -eq "${#minimum}" && "${normalized}" < "${minimum}" ]] ||
[[ "${#normalized}" -gt "${#maximum}" ]] ||
[[ "${#normalized}" -eq "${#maximum}" && "${normalized}" > "${maximum}" ]]; then
echo "[entrypoint] ${name} must be an integer from ${minimum} to ${maximum}" >&2
exit 1
fi
}

validate_filename() {
local name="$1" value="$2"
if [[ ! "${value}" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]*$ ]]; then
echo "[entrypoint] ${name} must be a filename containing only letters, numbers, dot, underscore, or hyphen" >&2
exit 1
fi
}

validate_boolean "BOTS" "${BOTS}"
validate_boolean "NOMASTER" "${NOMASTER}"
validate_boolean "LAN_MODE" "${LAN_MODE}"
validate_integer "MAXPLAYERS" "${MAXPLAYERS}" 1 32
validate_integer "PORT" "${PORT}" 1 65535
validate_filename "MAP" "${MAP}"
validate_filename "MAPCYCLE" "${MAPCYCLE}"

# hlds_linux needs libs from its own directory (normally set by hlds_run)
export LD_LIBRARY_PATH=".:${LD_LIBRARY_PATH:-}"

FIFO=/tmp/hlds-input
RUNTIME_DIR="${HLDS_RUNTIME_DIR:-/hlds/.runtime}"
FIFO="${RUNTIME_DIR}/hlds-input"
SCRIPT_PID=
SHUTTING_DOWN=0

cleanup() {
exec 3>&- 3<&- || true
rm -f "${FIFO}"
}
trap cleanup EXIT

hlds_command() {
echo "$1" > "${FIFO}"
printf '%s\n' "$1" >&3
}
Comment on lines 75 to 77

graceful_shutdown() {
SHUTTING_DOWN=1
echo "[entrypoint] Caught shutdown signal, starting graceful shutdown..."

if [[ -n "${SCRIPT_PID}" ]] && kill -0 "${SCRIPT_PID}" 2>/dev/null; then
Expand All @@ -54,46 +100,65 @@ graceful_shutdown() {
hlds_command "quit"
sleep 2

# hlds_run's restart loop will try to respawn — kill the pipeline
# hlds_run's restart loop may try to respawn after the quit command.
kill "${SCRIPT_PID}" 2>/dev/null || true
fi

wait "${SCRIPT_PID}" 2>/dev/null || true
}
trap graceful_shutdown SIGTERM SIGINT

mkdir -p "${RUNTIME_DIR}"
chmod 700 "${RUNTIME_DIR}"
rm -f "${FIFO}"
mkfifo "${FIFO}"
chmod 600 "${FIFO}"
exec 3<> "${FIFO}"

if [[ "${BOTS}" = "1" ]]; then
BOT_QUOTA_ARGS="+bot_quota 10"
BOT_QUOTA_ARGS=(+bot_quota 10)
else
BOT_QUOTA_ARGS="+bot_quota 0"
BOT_QUOTA_ARGS=(+bot_quota 0)
fi

if [[ "${NOMASTER}" = "1" ]]; then
MASTER_ARGS="-nomaster -insecure"
MASTER_ARGS=(-nomaster -insecure)
else
MASTER_ARGS=""
MASTER_ARGS=()
fi

# Use 'script' to give hlds a PTY — without a PTY, hlds_linux does
# blocking reads on stdin which freezes the game loop.
# hlds_run handles Steam API init (first crash + auto-restart creates
# the IPC state needed for the second run to accept players).
tail -f "${FIFO}" | script -qfc "./hlds_run \
-game cstrike \
+map ${MAP} \
+maxplayers ${MAXPLAYERS} \
+port ${PORT} \
+sv_lan ${LAN_MODE} \
-pingboost 2 \
+sys_ticrate 1000 \
+mapcyclefile ${MAPCYCLE} \
+exec server.cfg \
${MASTER_ARGS} \
${BOT_QUOTA_ARGS}" /dev/null &
HLDS_ARGS=(
./hlds_run
-game cstrike
+map "${MAP}"
+maxplayers "${MAXPLAYERS}"
+port "${PORT}"
+sv_lan "${LAN_MODE}"
-pingboost 2
+sys_ticrate 1000
+mapcyclefile "${MAPCYCLE}"
+exec server.cfg
"${MASTER_ARGS[@]}"
"${BOT_QUOTA_ARGS[@]}"
)
printf -v HLDS_COMMAND '%q ' "${HLDS_ARGS[@]}"

script -eqfc "${HLDS_COMMAND}" /dev/null < "${FIFO}" &
SCRIPT_PID=$!

echo "[entrypoint] HLDS started (PID: ${SCRIPT_PID})"

wait "${SCRIPT_PID}" || true
set +e
wait "${SCRIPT_PID}"
STATUS=$?
set -e

if [[ "${SHUTTING_DOWN}" = "1" ]]; then
exit 0
fi

exit "${STATUS}"
Loading