diff --git a/.containerignore b/.containerignore index 7da7348..03c43a0 100644 --- a/.containerignore +++ b/.containerignore @@ -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 diff --git a/.env.example b/.env.example index 1f27b9e..53a97b2 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4a921c6..d447b2b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1d7ef5..0ccd906 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 23a2dee..202101a 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -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: diff --git a/.github/workflows/friday-dust2.yml b/.github/workflows/friday-dust2.yml index 17dc868..798bb3b 100644 --- a/.github/workflows/friday-dust2.yml +++ b/.github/workflows/friday-dust2.yml @@ -10,6 +10,9 @@ on: - cron: "0 8 * * 5" workflow_dispatch: +permissions: + contents: read + jobs: dust2-announcement: name: Friday Dust 2 Announcement diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 2994db5..20ee36f 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -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 @@ -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' diff --git a/CLAUDE.md b/CLAUDE.md index 5b02271..eaad467 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3ca3749..36e18bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 ``` diff --git a/Containerfile b/Containerfile index 7ea94b9..f594edf 100644 --- a/Containerfile +++ b/Containerfile @@ -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 && \ @@ -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 @@ -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/ @@ -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 @@ -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 diff --git a/README.md b/README.md index 9d4f742..cab3a1c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -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 @@ -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. diff --git a/compose.yml b/compose.yml index 0263203..5bd3bc5 100644 --- a/compose.yml +++ b/compose.yml @@ -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: @@ -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 diff --git a/entrypoint.sh b/entrypoint.sh index d3ca229..a6d0dae 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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}" @@ -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 } 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 @@ -54,7 +100,7 @@ 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 @@ -62,38 +108,57 @@ graceful_shutdown() { } 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}" diff --git a/install.sh b/install.sh index 9f780fe..9a803da 100755 --- a/install.sh +++ b/install.sh @@ -16,6 +16,7 @@ # 5. Starts the server # set -euo pipefail +umask 077 REPO="KevinTCoughlin/cs-server" REGISTRY="ghcr.io" @@ -26,6 +27,11 @@ CONFIG_DIR="${HOME}/.config/cs-server" QUADLET_DIR="${HOME}/.config/containers/systemd" RUNTIME="" +if [[ -z "${IMAGE}" || "${IMAGE}" =~ [[:space:]] ]]; then + printf '[error] Invalid CS_SERVER_IMAGE: %s\n' "${IMAGE}" >&2 + exit 1 +fi + # --- Helpers ---------------------------------------------------------------- info() { printf '\033[1;34m[info]\033[0m %s\n' "$1"; } @@ -194,6 +200,7 @@ do_install() { info "Creating config directory: ${CONFIG_DIR}" mkdir -p "${CONFIG_DIR}" + chmod 700 "${CONFIG_DIR}" if [[ ! -f "${CONFIG_DIR}/server.cfg" ]]; then local rcon_password @@ -273,6 +280,7 @@ NIPPERMAPCYCLE else ok "mapcycle-nipper.txt already exists, skipping" fi + chmod 600 "${CONFIG_DIR}"/*.cfg "${CONFIG_DIR}"/*.txt # --- Start server -------------------------------------------------------- @@ -292,6 +300,7 @@ install_podman() { cat > "${QUADLET_DIR}/${SERVICE_NAME}.container" << QUADLET [Unit] Description=CS 1.6 ScoutzKnivez Server +Wants=network-online.target After=network-online.target [Container] @@ -313,6 +322,11 @@ Sysctl=net.core.rmem_max=26214400 Sysctl=net.core.wmem_max=26214400 DropCapability=ALL NoNewPrivileges=true +HealthCmd=grep -qs hlds_linux /proc/[0-9]*/comm +HealthInterval=30s +HealthTimeout=5s +HealthStartPeriod=60s +HealthRetries=3 Notify=healthy [Service] diff --git a/justfile b/justfile index 4ae4f18..c122d45 100644 --- a/justfile +++ b/justfile @@ -49,7 +49,12 @@ shellcheck: shellcheck entrypoint.sh install.sh # Run all checks -check: lint shellcheck +check: lint shellcheck test + +# Run entrypoint regression tests +test: + bash tests/entrypoint_test.sh + bash tests/compose_test.sh # Exec into running container shell: @@ -57,7 +62,7 @@ shell: # Send RCON command to server via FIFO rcon cmd: - podman exec scoutzknivez sh -c 'echo "{{cmd}}" > /tmp/hlds-input' + podman exec scoutzknivez sh -c 'printf "%s\n" "$1" > /hlds/.runtime/hlds-input' sh "{{cmd}}" # Restart Quadlet service restart: diff --git a/plugins/amxmodx/AQS.ini b/plugins/amxmodx/AQS.ini index 78d2b2c..a1ddd68 100644 --- a/plugins/amxmodx/AQS.ini +++ b/plugins/amxmodx/AQS.ini @@ -49,7 +49,7 @@ JOIN DELAY SECONDS = 6.000000 # IF ENABLED, AFTER THE PLAYER HAS J # # DEFAULT = 1 # -SKIP EXISTING INFO = 1 +SKIP EXISTING INFO = 0 # WHEN ENABLED, DO NOT SHOW THE CHAT '/SOUNDS' COMMAND WHEN TYPED. # @@ -93,7 +93,7 @@ HUDMSG BLUE = _ # # DEFAULT = 1 # -SQL ON/ OFF = 1 +SQL ON/ OFF = 0 # SQL FULL OR FAST STEAM STORAGE. # diff --git a/quadlet/scoutzknivez.container b/quadlet/scoutzknivez.container index 57ee188..045076f 100644 --- a/quadlet/scoutzknivez.container +++ b/quadlet/scoutzknivez.container @@ -1,5 +1,6 @@ [Unit] Description=CS 1.6 ScoutzKnivez Server +Wants=network-online.target After=network-online.target [Container] @@ -21,6 +22,11 @@ Sysctl=net.core.rmem_max=26214400 Sysctl=net.core.wmem_max=26214400 DropCapability=ALL NoNewPrivileges=true +HealthCmd=grep -qs hlds_linux /proc/[0-9]*/comm +HealthInterval=30s +HealthTimeout=5s +HealthStartPeriod=60s +HealthRetries=3 Notify=healthy [Service] diff --git a/tests/compose_test.sh b/tests/compose_test.sh new file mode 100644 index 0000000..4ffaca8 --- /dev/null +++ b/tests/compose_test.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 Kevin T. Coughlin +set -euo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +cd "${ROOT}" + +if command -v docker-compose >/dev/null 2>&1; then + COMPOSE=(docker-compose) +elif docker compose version >/dev/null 2>&1; then + COMPOSE=(docker compose) +elif command -v podman-compose >/dev/null 2>&1; then + COMPOSE=(podman-compose) +else + printf 'No Compose implementation available\n' >&2 + exit 1 +fi + +assert_nomaster() { + local expected="$1" + local output + output=$(NOMASTER="${expected}" "${COMPOSE[@]}" config) + + if ! grep -Eq "NOMASTER: ['\"]?${expected}['\"]?$" <<<"${output}"; then + printf 'Expected rendered NOMASTER=%s\n' "${expected}" >&2 + exit 1 + fi +} + +assert_nomaster 0 +assert_nomaster 1 + +printf 'compose tests passed\n' diff --git a/tests/entrypoint_test.sh b/tests/entrypoint_test.sh new file mode 100644 index 0000000..0276371 --- /dev/null +++ b/tests/entrypoint_test.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 Kevin T. Coughlin +set -euo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +WORK_DIR="${ROOT}/tests/.entrypoint-work.$$" +trap 'rm -rf "${WORK_DIR}"' EXIT +mkdir -p "${WORK_DIR}" + +expect_invalid() { + local expected="$1" + shift + + local output status + set +e + output=$(env HLDS_RUNTIME_DIR="${WORK_DIR}/runtime" "$@" bash "${ROOT}/entrypoint.sh" 2>&1) + status=$? + set -e + + if [[ "${status}" -eq 0 ]] || [[ "${output}" != *"${expected}"* ]]; then + printf 'Expected failure containing %q, got status %d:\n%s\n' "${expected}" "${status}" "${output}" >&2 + exit 1 + fi +} + +expect_invalid "BOTS must be 0 or 1" BOTS=2 +expect_invalid "NOMASTER must be 0 or 1" NOMASTER=yes +expect_invalid "LAN_MODE must be 0 or 1" LAN_MODE=-1 +expect_invalid "MAXPLAYERS must be an integer from 1 to 32" MAXPLAYERS=33 +expect_invalid "MAXPLAYERS must be an integer from 1 to 32" MAXPLAYERS=999999999999999999999999999999999999 +expect_invalid "PORT must be an integer from 1 to 65535" PORT=0 +expect_invalid "PORT must be an integer from 1 to 65535" PORT=18446744073709551616 +expect_invalid "MAP must be a filename" "MAP=scoutzknivez;quit" +expect_invalid "MAPCYCLE must be a filename" MAPCYCLE=../server.cfg + +cat > "${WORK_DIR}/hlds_run" <<'EOF' +#!/bin/sh +exit 17 +EOF +chmod +x "${WORK_DIR}/hlds_run" + +run_stub() { + set +e + ( + cd "${WORK_DIR}" + env HLDS_RUNTIME_DIR="${WORK_DIR}/runtime" "$@" bash "${ROOT}/entrypoint.sh" + ) >/dev/null 2>&1 + status=$? + set -e +} + +run_stub MAXPLAYERS=00020 PORT=027015 +if [[ "${status}" -ne 17 ]]; then + printf 'Expected leading-zero values to reach HLDS, got %d\n' "${status}" >&2 + exit 1 +fi + +run_stub + +if [[ "${status}" -ne 17 ]]; then + printf 'Expected HLDS exit status 17, got %d\n' "${status}" >&2 + exit 1 +fi + +printf 'entrypoint tests passed\n'