From e4f1f95e14203b67e1d4057cec72161c921f3672 Mon Sep 17 00:00:00 2001 From: johnseth97 <17620345+johnseth97@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:04:14 -0400 Subject: [PATCH 1/7] security: harden container image and run steamcmd/wine unprivileged - Create a dedicated abiotic user (default UID/GID 10000:10000, overridden at runtime via PUID/PGID) instead of running everything as root. - Fetch SteamCMD from Valve's official tarball instead of Ubuntu's steamcmd apt package, which fails outright on this base image ("Steamcmd needs to be online to update" even with working network). - Add en_US.UTF-8 locale generation (upstream PR #25) to remove SteamCMD's setlocale warning. - Fix execute permissions on the extracted steamcmd tarball, which Valve ships without group/other execute bits. --- .dockerignore | 2 + Dockerfile | 90 +++++++++++++++++++++++++++++++++----- docker-compose.yml.example | 21 --------- 3 files changed, 82 insertions(+), 31 deletions(-) delete mode 100644 docker-compose.yml.example diff --git a/.dockerignore b/.dockerignore index 864179f..4f209a2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,4 @@ * !entrypoint.sh +!healthcheck/ +!healthcheck/query.py diff --git a/Dockerfile b/Dockerfile index 5fa14ba..1365e80 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,87 @@ +# syntax=docker/dockerfile:1 +# +# Abiotic Factor dedicated server (App ID 2857200) under Wine. +# +# The dedicated server is a Windows-only Steam Tool (confirmed via +# `steamcmd +app_info_print 2857200`: oslist=windows). There is no Linux +# build, so Wine is required. See docs/upstream-review.md for how the +# executable path and launch parameters below were verified against the +# current Steam depot rather than assumed from the old entrypoint. FROM ubuntu:22.04 +ARG ABIOTIC_UID=10000 +ARG ABIOTIC_GID=10000 +ARG IMAGE_REVISION=dev + +LABEL org.opencontainers.image.title="abiotic-factor-server" \ + org.opencontainers.image.revision="${IMAGE_REVISION}" \ + org.opencontainers.image.source="https://github.com/johnseth97/abiotic-factor-linux-docker" + +ENV DEBIAN_FRONTEND=noninteractive \ + PATH="/usr/games:${PATH}" \ + LANG=en_US.UTF-8 \ + LANGUAGE=en_US:en \ + LC_ALL=en_US.UTF-8 \ + WINEARCH=win64 \ + WINEDEBUG=-all + +# NOTE: we deliberately do NOT use Ubuntu's `steamcmd` apt package. It ships +# a years-stale bootstrap shim (steamcmd:i386 0~20180105-4 on 22.04) that, in +# testing on this host, failed outright with "Steamcmd needs to be online to +# update" even with working network access. We fetch the current, officially +# distributed steamcmd tarball from Valve instead - the same thing every +# well-regarded SteamCMD container does. See docs/upstream-review.md. +# +# locales fixes the setlocale warning from PR #25 +# (https://github.com/Pleut/abiotic-factor-linux-docker/pull/25); gosu drops +# privileges from root to the unprivileged runtime user; rsync backs the +# staged-update promotion in entrypoint.sh; python3 backs the health check. RUN dpkg --add-architecture i386 && \ apt-get update && \ - echo steam steam/question select "I AGREE" | debconf-set-selections && \ - echo steam steam/license note '' | debconf-set-selections && \ - DEBIAN_FRONTEND=noninteractive apt-get install -y wine64 steamcmd && \ - apt-get clean autoclean && \ - apt-get autoremove -y && \ - rm -rf /var/lib/apt/lists/* + apt-get install -y --no-install-recommends \ + wine64 \ + curl \ + ca-certificates \ + lib32gcc-s1 \ + locales \ + gosu \ + rsync \ + python3 \ + && mkdir -p /opt/steamcmd \ + && curl -sqL "https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz" \ + | tar zxf - -C /opt/steamcmd \ + && ln -s /opt/steamcmd/steamcmd.sh /opt/steamcmd/steamcmd \ + # Valve's tarball ships steamcmd.sh owned by an arbitrary uid (5020) with + # no execute bit for group/other, so the unprivileged runtime user can't + # run it as-is. Confirmed by testing - fails with "Permission denied". + && chmod -R a+rX /opt/steamcmd \ + && locale-gen en_US.UTF-8 \ + && update-locale LANG=en_US.UTF-8 \ + && apt-get clean autoclean \ + && apt-get autoremove -y \ + && rm -rf /var/lib/apt/lists/* + +ENV PATH="/opt/steamcmd:${PATH}" + +# Dedicated unprivileged user. UID/GID are overridden at container start +# (via usermod/groupmod in entrypoint.sh) to match PUID/PGID so bind-mounted +# host directories are writable without running the game as root. +RUN groupadd --gid "${ABIOTIC_GID}" abiotic && \ + useradd --uid "${ABIOTIC_UID}" --gid "${ABIOTIC_GID}" \ + --create-home --home-dir /home/abiotic --shell /usr/sbin/nologin abiotic && \ + mkdir -p /data/gamefiles /data/logs /home/abiotic/.wine && \ + chown -R abiotic:abiotic /data /home/abiotic -ENV PATH="$PATH:/usr/games" +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +COPY healthcheck/query.py /usr/local/bin/abiotic-healthcheck.py +RUN chmod 0755 /usr/local/bin/entrypoint.sh /usr/local/bin/abiotic-healthcheck.py -WORKDIR /steamcmd +WORKDIR /data -COPY ./entrypoint.sh /entrypoint.sh -ENTRYPOINT ["bash", "/entrypoint.sh"] +# Runs as root initially so the entrypoint can reconcile PUID/PGID and fix +# bind-mount ownership; entrypoint.sh drops to the unprivileged user via +# gosu before running steamcmd or wine. Zombie reaping/PID 1 signal handling +# is provided by Compose's `init: true` (Docker's own tini-equivalent) - a +# second init baked in here would just become an unnecessary PID 2 hop, so +# we don't duplicate it. +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/docker-compose.yml.example b/docker-compose.yml.example deleted file mode 100644 index 428410a..0000000 --- a/docker-compose.yml.example +++ /dev/null @@ -1,21 +0,0 @@ -services: - abiotic-server: - image: "ghcr.io/pleut/abiotic-factor-linux-docker:latest" - restart: unless-stopped - volumes: - - "./gamefiles:/server" - - "./data:/server/AbioticFactor/Saved" - environment: - - MaxServerPlayers=6 - - Port=7777 - - QueryPort=27015 - - ServerPassword=password - - SteamServerName=Linux Server - - UsePerfThreads=true - - NoAsyncLoadingThread=true - - WorldSaveName=Cascade -# - AutoUpdate=true -# - AdditionalArgs=-SandboxIniPath=Config/WindowsServer/Server1Sandbox.ini - ports: - - "0.0.0.0:7777:7777/udp" - - "0.0.0.0:27015:27015/udp" From 0a262f7a682c4ef9270ace4707d3abc04078a501 Mon Sep 17 00:00:00 2001 From: johnseth97 <17620345+johnseth97@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:06:44 -0400 Subject: [PATCH 2/7] fix: rewrite entrypoint with verified, staged, retryable updates - set -Eeuo pipefail, structured logging, no eval, no secrets logged. - Parse booleans/integers strictly; reject the shipped SERVER_PASSWORD placeholder while still allowing an explicitly empty password (fixes the upstream VAR:-default substitution bug behind issues #28/#29, where an intentionally empty password was silently replaced with a fixed default). - Verify installs by checking the executable's actual presence and the installed Steam build ID, never by trusting SteamCMD's exit code alone (issues #27/#24/#8, PR #26 - reimplemented with real exponential backoff). - Stage installs/updates in a directory outside any save-data bind mount and rsync into the live install dir excluding Saved/, so a failed or interrupted update can never touch save data (PR #23, reimplemented without find -delete against the live tree). - Detect and recover from SteamCMD's own self-update bootstrap getting stuck in a repeating failed state, independent of the game depot. - Forward SIGTERM/SIGINT to the game process with a bounded grace period before escalating to SIGKILL; verified with synthetic signal tests. --- entrypoint.sh | 431 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 398 insertions(+), 33 deletions(-) mode change 100644 => 100755 entrypoint.sh diff --git a/entrypoint.sh b/entrypoint.sh old mode 100644 new mode 100755 index 2e67b10..00732c0 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,33 +1,398 @@ -SetUsePerfThreads="-useperfthreads " -if [[ $UsePerfThreads == "false" ]]; then - SetUsePerfThreads="" -fi - -SetNoAsyncLoadingThread="-NoAsyncLoadingThread " -if [[ $NoAsyncLoadingThread == "false" ]]; then - SetNoAsyncLoadingThread="" -fi - -MaxServerPlayers="${MaxServerPlayers:-6}" -Port="${Port:-7777}" -QueryPort="${QueryPort:-27015}" -ServerPassword="${ServerPassword:-password}" -SteamServerName="${SteamServerName:-LinuxServer}" -WorldSaveName="${WorldSaveName:-Cascade}" -AdditionalArgs="${AdditionalArgs:-}" - -# Check for updates/perform initial installation -if [ ! -d "/server/AbioticFactor/Binaries/Win64" ] || [[ $AutoUpdate == "true" ]]; then - steamcmd \ - +@sSteamCmdForcePlatformType windows \ - +force_install_dir /server \ - +login anonymous \ - +app_update 2857200 validate \ - +quit -fi - -pushd /server/AbioticFactor/Binaries/Win64 > /dev/null -wine AbioticFactorServer-Win64-Shipping.exe $SetUsePerfThreads$SetNoAsyncLoadingThread-MaxServerPlayers=$MaxServerPlayers \ - -PORT=$Port -QueryPort=$QueryPort -ServerPassword=$ServerPassword \ - -SteamServerName="$SteamServerName" -WorldSaveName="$WorldSaveName" -tcp $AdditionalArgs -popd > /dev/null +#!/usr/bin/env bash +# Entrypoint for the Abiotic Factor dedicated server container. +# Runs as root until privileges are dropped; see reconcile_user() and the +# gosu calls in run_steamcmd_with_retries()/start_server(). +set -Eeuo pipefail + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- +log() { printf '[%(%Y-%m-%dT%H:%M:%SZ)T] [%s] %s\n' -1 "$1" "$2" >&2; } +log_info() { log "INFO" "$1"; } +log_warn() { log "WARN" "$1"; } +log_error() { log "ERROR" "$1"; } +fatal() { log_error "$1"; exit "${2:-1}"; } + +# --------------------------------------------------------------------------- +# Fixed internal paths (match the bind mounts declared in docker-compose.yml) +# --------------------------------------------------------------------------- +readonly GAMEFILES_DIR="/data/gamefiles" +readonly STAGING_DIR="/data/gamefiles.staging" +readonly SAVE_SUBPATH="AbioticFactor/Saved" +readonly EXE_SUBPATH="AbioticFactor/Binaries/Win64/AbioticFactorServer-Win64-Shipping.exe" +readonly LOG_DIR="/data/logs" +readonly ABIOTIC_USER="abiotic" + +# --------------------------------------------------------------------------- +# Boolean / integer env parsing helpers - reject nonsensical values instead +# of silently coercing them. +# --------------------------------------------------------------------------- +parse_bool() { + local name="$1" value="${2,,}" + case "$value" in + true|1|yes|on) echo "true" ;; + false|0|no|off) echo "false" ;; + *) fatal "Invalid boolean value for ${name}: '${2}' (expected true/false)" ;; + esac +} + +parse_uint() { + local name="$1" value="$2" + [[ "$value" =~ ^[0-9]+$ ]] || fatal "Invalid unsigned integer for ${name}: '${value}'" + echo "$value" +} + +# alias_env NEW_NAME OLD_NAME DEFAULT - prefers NEW_NAME, falls back to the +# upstream OLD_NAME for backward compatibility, then DEFAULT. +alias_env() { + local new_name="$1" old_name="$2" default="$3" + local new_val="${!new_name-}" old_val="${!old_name-}" + if [[ -n "${!new_name+set}" ]]; then + echo "$new_val" + elif [[ -n "${!old_name+set}" ]]; then + echo "$old_val" + else + echo "$default" + fi +} + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- +load_config() { + PUID="$(parse_uint PUID "${PUID:-10000}")" + PGID="$(parse_uint PGID "${PGID:-10000}")" + + STEAM_APP_ID="$(parse_uint STEAM_APP_ID "${STEAM_APP_ID:-2857200}")" + STEAM_BRANCH="${STEAM_BRANCH:-public}" + + AUTO_UPDATE="$(parse_bool AUTO_UPDATE "$(alias_env AUTO_UPDATE AutoUpdate true)")" + VALIDATE_ON_START="$(parse_bool VALIDATE_ON_START "${VALIDATE_ON_START:-true}")" + STEAMCMD_RETRIES="$(parse_uint STEAMCMD_RETRIES "${STEAMCMD_RETRIES:-5}")" + SHUTDOWN_TIMEOUT="$(parse_uint SHUTDOWN_TIMEOUT "${SHUTDOWN_TIMEOUT:-30}")" + + SERVER_NAME="$(alias_env SERVER_NAME SteamServerName "Abiotic Factor Dedicated Server")" + SERVER_PASSWORD="$(alias_env SERVER_PASSWORD ServerPassword "replace-me")" + ADMIN_PASSWORD="${ADMIN_PASSWORD:-}" + GAME_PORT="$(parse_uint GAME_PORT "$(alias_env GAME_PORT Port 7777)")" + QUERY_PORT="$(parse_uint QUERY_PORT "$(alias_env QUERY_PORT QueryPort 27015)")" + MAX_SERVER_PLAYERS="$(parse_uint MAX_SERVER_PLAYERS "$(alias_env MAX_SERVER_PLAYERS MaxServerPlayers 6)")" + WORLD_SAVE_NAME="$(alias_env WORLD_SAVE_NAME WorldSaveName Cascade)" + USE_PERF_THREADS="$(parse_bool USE_PERF_THREADS "$(alias_env USE_PERF_THREADS UsePerfThreads true)")" + DISABLE_ASYNC_LOADING_THREAD="$(parse_bool DISABLE_ASYNC_LOADING_THREAD "$(alias_env DISABLE_ASYNC_LOADING_THREAD NoAsyncLoadingThread false)")" + ADDITIONAL_ARGS="$(alias_env ADDITIONAL_ARGS AdditionalArgs "")" + + # Refuse the documented .env.example placeholder outright. An explicitly + # empty string is a deliberate choice (open server, no password) and is + # allowed - see docs/upstream-review.md for why the old entrypoint's + # ${VAR:-default} handling of this was wrong (issues #28/#29). + if [[ "$SERVER_PASSWORD" == "replace-me" ]]; then + fatal "SERVER_PASSWORD is still set to the placeholder 'replace-me'. Set a real password in .env, or set SERVER_PASSWORD= (empty) explicitly for an open server." + fi +} + +# --------------------------------------------------------------------------- +# Privilege handling +# --------------------------------------------------------------------------- +reconcile_user() { + local current_uid current_gid + current_uid="$(id -u "$ABIOTIC_USER")" + current_gid="$(id -g "$ABIOTIC_USER")" + + if [[ "$current_gid" != "$PGID" ]]; then + log_info "Adjusting ${ABIOTIC_USER} GID ${current_gid} -> ${PGID}" + groupmod --gid "$PGID" "$ABIOTIC_USER" + fi + if [[ "$current_uid" != "$PUID" ]]; then + log_info "Adjusting ${ABIOTIC_USER} UID ${current_uid} -> ${PUID}" + usermod --uid "$PUID" --non-unique "$ABIOTIC_USER" + # Recursive: the home directory may already contain files owned by + # the old UID (Wine prefix, SteamCMD's own bootstrap cache) that + # would otherwise become unwritable by the newly-renumbered user. + chown -Rh "$PUID:$PGID" /home/"$ABIOTIC_USER" + fi +} + +# chown a directory tree only if it doesn't already belong to PUID:PGID, to +# avoid a slow recursive chown on every restart once ownership is correct. +ensure_owned() { + local dir="$1" + mkdir -p "$dir" + local owner + owner="$(stat -c '%u:%g' "$dir")" + if [[ "$owner" != "${PUID}:${PGID}" ]]; then + log_info "Fixing ownership of ${dir} (was ${owner})" + chown -R "${PUID}:${PGID}" "$dir" \ + || fatal "Could not chown ${dir} to ${PUID}:${PGID}; check that its host bind mount is writable and not read-only" + fi +} + +prepare_dirs() { + mkdir -p "$GAMEFILES_DIR" "$LOG_DIR" + ensure_owned "$GAMEFILES_DIR" + ensure_owned "$LOG_DIR" + # The Saved/Config subpaths are separate bind mounts nested inside + # GAMEFILES_DIR (see docker-compose.yml); they exist by the time we get + # here only if Docker created the mountpoints already. Fix ownership on + # whichever of them are present without recursing into the (possibly + # large) rest of gamefiles twice. + for sub in "$SAVE_SUBPATH" "$SAVE_SUBPATH/Config"; do + [[ -d "$GAMEFILES_DIR/$sub" ]] && ensure_owned "$GAMEFILES_DIR/$sub" + done + if ! gosu "$ABIOTIC_USER" test -w "$GAMEFILES_DIR"; then + fatal "${GAMEFILES_DIR} is not writable by ${ABIOTIC_USER} (${PUID}:${PGID}); check the host bind mount permissions" + fi +} + +# --------------------------------------------------------------------------- +# SteamCMD / update handling +# --------------------------------------------------------------------------- + +# Installed build id, read from the depot's own appmanifest. Empty if unknown. +installed_build_id() { + local acf="$1/steamapps/appmanifest_${STEAM_APP_ID}.acf" + [[ -f "$acf" ]] || { echo ""; return; } + sed -n 's/^\s*"buildid"\s*"\([0-9]*\)".*/\1/p' "$acf" | head -n1 +} + +# Best-effort lookup of the latest build id on STEAM_BRANCH. Never fatal - +# a Steam metadata hiccup should not prevent starting an already-installed +# server. +latest_build_id() { + local out + if ! out="$(gosu "$ABIOTIC_USER" env HOME=/home/"$ABIOTIC_USER" steamcmd \ + +@sSteamCmdForcePlatformType windows \ + +login anonymous \ + +app_info_print "$STEAM_APP_ID" \ + +quit 2>&1)"; then + log_warn "Could not query latest build id from Steam (non-fatal)" + echo "" + return + fi + echo "$out" | awk -v branch="\"${STEAM_BRANCH}\"" ' + $0 ~ branch { in_branch=1; depth=0; next } + in_branch && /{/ { depth++ } + in_branch && /}/ { depth--; if (depth < 0) { in_branch=0 } } + in_branch && /"buildid"/ { + gsub(/[^0-9]/, "", $2); print $2; exit + }' +} + +verify_install() { + local dir="$1" + [[ -d "$dir" ]] || { log_warn "verify_install: ${dir} does not exist"; return 1; } + [[ -f "$dir/$EXE_SUBPATH" ]] || { log_warn "verify_install: expected executable missing at ${dir}/${EXE_SUBPATH}"; return 1; } + [[ -s "$dir/$EXE_SUBPATH" ]] || { log_warn "verify_install: executable at ${dir}/${EXE_SUBPATH} is zero bytes"; return 1; } + return 0 +} + +# Runs steamcmd with bounded exponential backoff. Treats "exited zero but the +# executable is still missing" as a failure, per PR #26 / issue #27 - an +# exit code of 0 alone is never treated as success. +run_steamcmd_with_retries() { + local install_dir="$1" + local attempt=1 delay=5 + while (( attempt <= STEAMCMD_RETRIES )); do + log_info "SteamCMD install attempt ${attempt}/${STEAMCMD_RETRIES} -> ${install_dir}" + local log_file + log_file="${LOG_DIR}/steamcmd-$(date -u +%Y%m%dT%H%M%SZ)-attempt${attempt}.log" + + # "public" is the default branch and is not a valid -beta selector - + # only pass -beta for an actual non-default branch (e.g. "experimental"). + local -a beta_args=() + [[ "$STEAM_BRANCH" != "public" ]] && beta_args=(-beta "$STEAM_BRANCH") + + set +e + gosu "$ABIOTIC_USER" env HOME=/home/"$ABIOTIC_USER" steamcmd \ + +@sSteamCmdForcePlatformType windows \ + +force_install_dir "$install_dir" \ + +login anonymous \ + +app_update "$STEAM_APP_ID" "${beta_args[@]}" validate \ + +quit > "$log_file" 2>&1 + local rc=$? + set -e + tail -n 20 "$log_file" >&2 || true + + if (( rc == 0 )) && verify_install "$install_dir"; then + log_info "SteamCMD install succeeded on attempt ${attempt}" + return 0 + fi + + if grep -qi "state is 0x6" "$log_file" 2>/dev/null; then + log_warn "Detected known transient 'state is 0x6' depot error (see docs/upstream-review.md, PR #23)" + fi + + # SteamCMD's own self-update bootstrap can get stuck in a + # perpetually-failing "pending update" loop if its first self-update + # download is corrupted/truncated (observed directly: "Fatal Error: + # Download of package (steamcmd_public_all) failed after 0 bytes" + # followed by every subsequent attempt re-failing the same pending + # update). That state lives entirely in steamcmd's own bootstrap + # cache under $HOME/Steam - NOT in any of our persistent volumes - + # so it is safe to clear and force a clean self-update on the next + # attempt. + if grep -qi "pending update\|failed after 0 bytes\|http error\|checksum mismatch" "$log_file" 2>/dev/null; then + log_warn "Detected a stuck SteamCMD self-update; clearing its bootstrap cache before retrying" + gosu "$ABIOTIC_USER" rm -rf "/home/${ABIOTIC_USER}/Steam" + fi + + log_warn "SteamCMD attempt ${attempt} failed (exit=${rc}, executable present=$(verify_install "$install_dir" && echo yes || echo no)); retrying in ${delay}s" + sleep "$delay" + delay=$(( delay * 2 )) + attempt=$(( attempt + 1 )) + done + return 1 +} + +# Fresh install/update always happens in a staging directory that is NOT +# inside any bind mount used for saves, then is copied into the live +# gamefiles directory with Saved/ excluded. A true atomic directory swap +# (rename staging -> live) was considered but rejected: runtime/data and +# runtime/config are bind-mounted at nested paths *inside* gamefiles +# (.../AbioticFactor/Saved and .../AbioticFactor/Saved/Config), and renaming +# a directory out from under active bind mounts is not something we're +# willing to rely on. rsync into the live tree is slightly less atomic but +# never risks the save data. See docs/operations.md "Update strategy". +stage_and_promote_update() { + rm -rf "$STAGING_DIR" + mkdir -p "$STAGING_DIR" + chown "${PUID}:${PGID}" "$STAGING_DIR" + + if ! run_steamcmd_with_retries "$STAGING_DIR"; then + rm -rf "$STAGING_DIR" + fatal "SteamCMD failed to produce a valid install after ${STEAMCMD_RETRIES} attempts. Leaving the previous installation in ${GAMEFILES_DIR} untouched." 3 + fi + + log_info "Promoting staged install into ${GAMEFILES_DIR} (excluding ${SAVE_SUBPATH})" + gosu "$ABIOTIC_USER" rsync -a --delete \ + --exclude "${SAVE_SUBPATH}" \ + "${STAGING_DIR}/" "${GAMEFILES_DIR}/" + + if ! verify_install "$GAMEFILES_DIR"; then + fatal "Promotion completed but ${GAMEFILES_DIR}/${EXE_SUBPATH} is still missing after rsync; refusing to start. Staged copy retained at ${STAGING_DIR} for inspection." 3 + fi + rm -rf "$STAGING_DIR" +} + +update_server() { + local installed latest + installed="$(installed_build_id "$GAMEFILES_DIR")" + log_info "Installed build id: ${installed:-none}" + + if ! verify_install "$GAMEFILES_DIR"; then + log_info "No valid installation found; performing initial install" + stage_and_promote_update + return + fi + + if [[ "$AUTO_UPDATE" != "true" ]]; then + log_info "AUTO_UPDATE=false; skipping update check, using existing installation" + [[ "$VALIDATE_ON_START" == "true" ]] && validate_in_place + return + fi + + latest="$(latest_build_id)" + log_info "Latest build id on '${STEAM_BRANCH}': ${latest:-unknown}" + + if [[ -n "$latest" && "$latest" == "$installed" ]]; then + log_info "Already up to date (build ${installed}); skipping full update" + [[ "$VALIDATE_ON_START" == "true" ]] && validate_in_place + return + fi + + log_info "Update available (installed=${installed:-none}, latest=${latest:-unknown}); staging update" + stage_and_promote_update +} + +# In-place validate is safe to run directly against the live gamefiles dir: +# the Steam depot's own file manifest never includes AbioticFactor/Saved +# (that tree is generated by the game at runtime, not shipped by Steam), so +# `validate` cannot touch save data even running in place. +validate_in_place() { + log_info "Running in-place validate against existing installation" + if ! run_steamcmd_with_retries "$GAMEFILES_DIR"; then + log_warn "In-place validate failed after retries; continuing with existing installation since it was already verified present. Escalating to a staged reinstall." + stage_and_promote_update + fi +} + +# --------------------------------------------------------------------------- +# Server process +# --------------------------------------------------------------------------- +CHILD_PID="" + +# shellcheck disable=SC2329 # invoked indirectly via `trap` below +term_handler() { + log_info "Received shutdown signal" + if [[ -n "$CHILD_PID" ]] && kill -0 "$CHILD_PID" 2>/dev/null; then + log_info "Forwarding SIGTERM to game process (pid ${CHILD_PID}); waiting up to ${SHUTDOWN_TIMEOUT}s" + kill -TERM "$CHILD_PID" 2>/dev/null || true + local waited=0 + while kill -0 "$CHILD_PID" 2>/dev/null && (( waited < SHUTDOWN_TIMEOUT )); do + sleep 1 + waited=$(( waited + 1 )) + done + if kill -0 "$CHILD_PID" 2>/dev/null; then + log_warn "Game process still running after ${SHUTDOWN_TIMEOUT}s; sending SIGKILL" + kill -KILL "$CHILD_PID" 2>/dev/null || true + else + log_info "Game process exited gracefully after ${waited}s" + fi + fi +} +trap term_handler SIGTERM SIGINT + +start_server() { + verify_install "$GAMEFILES_DIR" || fatal "Cannot start: ${GAMEFILES_DIR}/${EXE_SUBPATH} is missing" + + local -a args=( + "-log" "-newconsole" + "-PORT=${GAME_PORT}" + "-QueryPort=${QUERY_PORT}" + "-ServerPassword=${SERVER_PASSWORD}" + "-SteamServerName=${SERVER_NAME}" + "-MaxServerPlayers=${MAX_SERVER_PLAYERS}" + "-WorldSaveName=${WORLD_SAVE_NAME}" + ) + [[ "$USE_PERF_THREADS" == "true" ]] && args+=("-useperfthreads") + [[ "$DISABLE_ASYNC_LOADING_THREAD" == "true" ]] && args+=("-DisableAsyncLoadingThread") + [[ -n "$ADMIN_PASSWORD" ]] && args+=("-AdminPassword=${ADMIN_PASSWORD}") + if [[ -n "$ADDITIONAL_ARGS" ]]; then + # shellcheck disable=SC2206 # intentional word-splitting of an + # operator-supplied argument string; no eval is used. + local -a extra=($ADDITIONAL_ARGS) + args+=("${extra[@]}") + fi + + log_info "Steam App ID: ${STEAM_APP_ID}" + log_info "Installed build id: $(installed_build_id "$GAMEFILES_DIR")" + log_info "Server executable: ${GAMEFILES_DIR}/${EXE_SUBPATH}" + log_info "Wine version: $(wine --version 2>&1)" + log_info "Image revision: ${IMAGE_REVISION:-dev}" + log_info "Ports: game=${GAME_PORT}/udp query=${QUERY_PORT}/udp" + log_info "Save path: ${GAMEFILES_DIR}/${SAVE_SUBPATH}" + log_info "Starting server (this may take a while on first launch as Wine initializes its prefix)" + + cd "${GAMEFILES_DIR}/AbioticFactor/Binaries/Win64" + gosu "$ABIOTIC_USER" env HOME=/home/"$ABIOTIC_USER" \ + wine "AbioticFactorServer-Win64-Shipping.exe" "${args[@]}" & + CHILD_PID=$! + wait "$CHILD_PID" + local exit_code=$? + log_info "Game process exited with code ${exit_code}" + exit "$exit_code" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +main() { + [[ "$(id -u)" == "0" ]] || fatal "entrypoint.sh must start as root to reconcile PUID/PGID (got uid $(id -u))" + load_config + reconcile_user + prepare_dirs + update_server + start_server +} + +main "$@" From 53d74f5e99030ffc04a3f41595d021cc77b0d891 Mon Sep 17 00:00:00 2001 From: johnseth97 <17620345+johnseth97@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:06:44 -0400 Subject: [PATCH 3/7] feat: add A2S_INFO based health check Queries the actual query port with a Source-engine style A2S_INFO request instead of just checking that PID 1 exists. Falls back to a process+socket check if the game hasn't finished initializing yet. --- healthcheck/query.py | 83 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100755 healthcheck/query.py diff --git a/healthcheck/query.py b/healthcheck/query.py new file mode 100755 index 0000000..4835c5b --- /dev/null +++ b/healthcheck/query.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""Health check for the Abiotic Factor dedicated server. + +Sends a Source-engine style A2S_INFO query to the configured query port and +treats a well-formed reply as "healthy". This proves the game process is +alive, has bound the query UDP socket, and is actually answering server-list +queries - the same mechanism Steam's own server browser relies on. + +What this does NOT prove: + * That the world/save data loaded correctly. + * That any particular player can actually join (firewall/NAT issues + upstream of this host are invisible to a query made from inside the + container's own network namespace). + * Anything about game logic correctness. + +If the query port has never answered by the time QUERY_TIMEOUT is reached, +falls back to checking that a wine process for the server binary is running +and that the query UDP port is bound at all (a weaker signal: the process +is up but may not have finished initializing). +""" +import socket +import subprocess +import sys +import os + +A2S_INFO_REQUEST = b"\xFF\xFF\xFF\xFF TSource Engine Query\x00" +TIMEOUT_SECONDS = float(os.environ.get("HEALTHCHECK_TIMEOUT", "2")) + + +def query_a2s_info(host: str, port: int) -> bool: + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.settimeout(TIMEOUT_SECONDS) + sock.sendto(A2S_INFO_REQUEST, (host, port)) + data, _ = sock.recvfrom(4096) + except OSError: + return False + # A2S_INFO replies start with 0xFFFFFFFF followed by 'I' (0x49), or a + # challenge response 'A' (0x41) that we do not bother completing - if + # the server is soliciting a challenge it is unambiguously alive. + return len(data) > 5 and data[:4] == b"\xFF\xFF\xFF\xFF" and data[4] in (0x49, 0x41) + + +def process_alive() -> bool: + try: + out = subprocess.run( + ["pgrep", "-f", "AbioticFactorServer-Win64-Shipping.exe"], + capture_output=True, timeout=5, + ) + return out.returncode == 0 + except Exception: + return False + + +def udp_port_bound(port: int) -> bool: + try: + with open("/proc/net/udp") as f: + hexport = format(port, "04X") + return any(f":{hexport} " in line for line in f) + except OSError: + return False + + +def main() -> int: + query_port = int(os.environ.get("QUERY_PORT", "27015")) + + if query_a2s_info("127.0.0.1", query_port): + print(f"healthy: A2S_INFO responded on query port {query_port}") + return 0 + + if process_alive() and udp_port_bound(query_port): + print( + f"degraded-but-alive: game process running and UDP {query_port} " + "bound, but A2S_INFO did not respond (still initializing?)" + ) + return 0 + + print(f"unhealthy: no A2S_INFO response and no confirmed live process/port {query_port}") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) From da50fedf084e4193810075a24aadf6f1fea39b1a Mon Sep 17 00:00:00 2001 From: johnseth97 <17620345+johnseth97@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:06:44 -0400 Subject: [PATCH 4/7] feat: add production docker-compose configuration Modern compose syntax with no top-level version field, locally built image, init: true, restart: no since systemd supervises the stack, cap_drop: ALL with a minimal cap_add for the entrypoint's root-phase bootstrap, no-new-privileges, Z-labeled bind mounts (required on this SELinux host, see docs/upstream-review.md), a health check, and json-file log rotation. Supersedes docker-compose.yml.example, which pointed at an unrelated image and had none of the above. --- .env.example | 49 +++++++++++++++++++++++++++++ .gitignore | 5 +-- docker-compose.yml | 77 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 .env.example create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ff22dd1 --- /dev/null +++ b/.env.example @@ -0,0 +1,49 @@ +# Copy this file to .env (untracked - never commit real secrets) and edit +# the values below. See README.md and docs/operations.md for details. + +# --- Identity / permissions ------------------------------------------------- +# UID/GID the game server runs as inside the container. Must be able to +# write to the runtime/ directories on the host. Defaults match the image's +# built-in "abiotic" user. +PUID=10000 +PGID=10000 + +# --- Update behavior --------------------------------------------------------- +STEAM_APP_ID=2857200 +STEAM_BRANCH=public +AUTO_UPDATE=true +VALIDATE_ON_START=true +STEAMCMD_RETRIES=5 + +# --- Server configuration ----------------------------------------------------- +SERVER_NAME=Abiotic Factor Dedicated Server +# REQUIRED. There is no safe default - the container refuses to start if +# this is left as "replace-me". Set a real password, or set it to an empty +# value (SERVER_PASSWORD=) for an intentionally open server. +SERVER_PASSWORD=replace-me +# Optional. Grants in-game admin whitelisting when set. Leave empty to disable. +ADMIN_PASSWORD= +GAME_PORT=7777 +QUERY_PORT=27015 +MAX_SERVER_PLAYERS=6 +WORLD_SAVE_NAME=Cascade +USE_PERF_THREADS=true +DISABLE_ASYNC_LOADING_THREAD=false +# Extra raw launch arguments appended verbatim, e.g.: +# ADDITIONAL_ARGS=-SandboxIniPath=Config/WindowsServer/Server1Sandbox.ini +ADDITIONAL_ARGS= + +# --- Shutdown ------------------------------------------------------------------ +# Seconds to wait for the game process to exit after SIGTERM before SIGKILL. +SHUTDOWN_TIMEOUT=60 + +# --- Backups --------------------------------------------------------------- +BACKUP_RETENTION=14 + +# --- Misc ------------------------------------------------------------------ +TZ=America/Chicago + +# --- Deployment (used by docker-compose.yml and systemd units, not the +# container itself) ---------------------------------------------------------- +RUNTIME_DIR=/srv/abiotic-factor/runtime +IMAGE_REVISION=dev diff --git a/.gitignore b/.gitignore index 1491883..8354f8a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ -data/ -gamefiles/ +.env +runtime/ +*.log diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1628130 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,77 @@ +services: + abiotic-factor: + build: + context: . + dockerfile: Dockerfile + args: + ABIOTIC_UID: "${PUID:-10000}" + ABIOTIC_GID: "${PGID:-10000}" + IMAGE_REVISION: "${IMAGE_REVISION:-dev}" + image: "local/abiotic-factor-server:${IMAGE_REVISION:-dev}" + init: true + restart: "no" # systemd (deploy/systemd/abiotic-factor.service) supervises the stack + stop_grace_period: 90s + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + # The entrypoint briefly runs as root before dropping to the + # unprivileged user (see entrypoint.sh: reconcile_user/prepare_dirs). + # Even as UID 0, that bootstrap needs these back after `cap_drop: ALL`: + # CHOWN to fix bind-mount ownership, SETUID/SETGID for gosu's privilege + # drop, DAC_OVERRIDE for usermod/groupmod to update /etc/passwd /etc/group. + # Confirmed necessary by testing - without them the entrypoint fails with + # "Operation not permitted" on its very first chown. + cap_add: + - CHOWN + - SETUID + - SETGID + - DAC_OVERRIDE + environment: + PUID: "${PUID:-10000}" + PGID: "${PGID:-10000}" + STEAM_APP_ID: "${STEAM_APP_ID:-2857200}" + STEAM_BRANCH: "${STEAM_BRANCH:-public}" + AUTO_UPDATE: "${AUTO_UPDATE:-true}" + VALIDATE_ON_START: "${VALIDATE_ON_START:-true}" + STEAMCMD_RETRIES: "${STEAMCMD_RETRIES:-5}" + SHUTDOWN_TIMEOUT: "${SHUTDOWN_TIMEOUT:-60}" + SERVER_NAME: "${SERVER_NAME:-Abiotic Factor Dedicated Server}" + # Non-colon form: only fails if SERVER_PASSWORD is entirely unset in + # .env, not if it's explicitly set to empty (an intentional open + # server - see entrypoint.sh's own placeholder check, which is what + # actually rejects the unchanged "replace-me" default). + SERVER_PASSWORD: "${SERVER_PASSWORD?SERVER_PASSWORD must be set in .env - use an empty value for an intentionally open server}" + ADMIN_PASSWORD: "${ADMIN_PASSWORD:-}" + GAME_PORT: "${GAME_PORT:-7777}" + QUERY_PORT: "${QUERY_PORT:-27015}" + MAX_SERVER_PLAYERS: "${MAX_SERVER_PLAYERS:-6}" + WORLD_SAVE_NAME: "${WORLD_SAVE_NAME:-Cascade}" + USE_PERF_THREADS: "${USE_PERF_THREADS:-true}" + DISABLE_ASYNC_LOADING_THREAD: "${DISABLE_ASYNC_LOADING_THREAD:-false}" + ADDITIONAL_ARGS: "${ADDITIONAL_ARGS:-}" + TZ: "${TZ:-UTC}" + ports: + - "0.0.0.0:${GAME_PORT:-7777}:${GAME_PORT:-7777}/udp" + - "0.0.0.0:${QUERY_PORT:-27015}:${QUERY_PORT:-27015}/udp" + volumes: + # :Z relabels these host directories to the SELinux container_file_t + # type (private to this stack). Confirmed necessary on this host - + # without it, SteamCMD silently fails to persist any installed files + # to a bind-mounted directory even while reporting success. See + # docs/upstream-review.md and docs/troubleshooting.md. + - "${RUNTIME_DIR:-./runtime}/gamefiles:/data/gamefiles:Z" + - "${RUNTIME_DIR:-./runtime}/data:/data/gamefiles/AbioticFactor/Saved:Z" + - "${RUNTIME_DIR:-./runtime}/config:/data/gamefiles/AbioticFactor/Saved/Config:Z" + - "${RUNTIME_DIR:-./runtime}/logs:/data/logs:Z" + healthcheck: + test: ["CMD", "python3", "/usr/local/bin/abiotic-healthcheck.py"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 10m # first boot: SteamCMD install (~3GB) + Wine prefix init + logging: + driver: json-file + options: + max-size: "20m" + max-file: "5" From f1af0d8a41bf8bc01932c8bcafb834cf51fadac2 Mon Sep 17 00:00:00 2001 From: johnseth97 <17620345+johnseth97@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:06:44 -0400 Subject: [PATCH 5/7] feat: add backup tooling Stops the service for a consistent snapshot, since no live-save command exists for this server, archives data/ and config/ only, verifies the resulting archive is readable, and prunes to a configurable retention without ever deleting the newest backup. --- scripts/backup.sh | 98 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100755 scripts/backup.sh diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100755 index 0000000..857d916 --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Back up Abiotic Factor save data and configuration. +# +# There is no documented in-game console command or RCON call to force a +# consistent save-and-continue on this dedicated server (see +# docs/upstream-review.md). To avoid capturing a partially-written save, this +# script stops the systemd-managed stack, archives runtime/data and +# runtime/config, and starts the stack back up. This means a backup causes a +# short window of server downtime; that trade-off is documented in +# docs/operations.md. +set -Eeuo pipefail + +log() { printf '[%(%Y-%m-%dT%H:%M:%SZ)T] [backup] %s\n' -1 "$1" >&2; } +fatal() { log "ERROR: $1"; exit 1; } + +RUNTIME_DIR="${RUNTIME_DIR:-/srv/abiotic-factor/runtime}" +BACKUP_DIR="${BACKUP_DIR:-${RUNTIME_DIR}/backups}" +RETENTION="${BACKUP_RETENTION:-14}" +SERVICE_NAME="${SERVICE_NAME:-abiotic-factor.service}" +SKIP_SERVICE_TOGGLE="${SKIP_SERVICE_TOGGLE:-false}" + +[[ "$RETENTION" =~ ^[0-9]+$ ]] || fatal "BACKUP_RETENTION must be a non-negative integer, got '${RETENTION}'" + +[[ -d "${RUNTIME_DIR}/data" ]] || fatal "${RUNTIME_DIR}/data does not exist; nothing to back up" + +mkdir -p "$BACKUP_DIR" + +timestamp="$(date -u +%Y%m%dT%H%M%SZ)" +archive_name="abiotic-factor-${timestamp}.tar.gz" +archive_path="${BACKUP_DIR}/${archive_name}" +tmp_path="${archive_path}.partial" + +service_was_active="false" +stop_service() { + if [[ "$SKIP_SERVICE_TOGGLE" == "true" ]]; then + log "SKIP_SERVICE_TOGGLE=true; not stopping ${SERVICE_NAME} (caller is responsible for consistency)" + return + fi + if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then + service_was_active="true" + log "Stopping ${SERVICE_NAME} for a consistent backup" + systemctl stop "$SERVICE_NAME" + else + log "${SERVICE_NAME} is not active; backing up without stopping" + fi +} + +restart_service_if_needed() { + if [[ "$SKIP_SERVICE_TOGGLE" == "true" ]]; then + return + fi + if [[ "$service_was_active" == "true" ]]; then + log "Restarting ${SERVICE_NAME}" + systemctl start "$SERVICE_NAME" + fi +} +trap restart_service_if_needed EXIT + +stop_service + +log "Archiving ${RUNTIME_DIR}/data and ${RUNTIME_DIR}/config -> ${archive_path}" +# Game binaries in runtime/gamefiles are intentionally excluded - SteamCMD +# can redownload them, and they dominate the size of a backup for no benefit. +tar_args=(-czf "$tmp_path" -C "$RUNTIME_DIR") +[[ -d "${RUNTIME_DIR}/data" ]] && tar_args+=(data) +[[ -d "${RUNTIME_DIR}/config" ]] && tar_args+=(config) +if ! tar "${tar_args[@]}"; then + rm -f "$tmp_path" + fatal "tar failed while creating ${archive_path}" +fi + +# Verify the archive is actually readable before trusting it or touching +# retention. +if ! tar -tzf "$tmp_path" > /dev/null; then + rm -f "$tmp_path" + fatal "Backup archive failed integrity verification (tar -tzf); discarded ${tmp_path}" +fi + +mv "$tmp_path" "$archive_path" +log "Backup verified OK: ${archive_path} ($(du -h "$archive_path" | cut -f1))" + +# Retention: keep the newest $RETENTION archives, oldest deleted first. The +# archive just created is always the newest and is therefore never deleted +# by this pass. +mapfile -t existing < <(find "$BACKUP_DIR" -maxdepth 1 -name 'abiotic-factor-*.tar.gz' -printf '%T@ %p\n' | sort -n | cut -d' ' -f2-) +count="${#existing[@]}" +if (( RETENTION > 0 && count > RETENTION )); then + to_delete=$(( count - RETENTION )) + log "Retention is ${RETENTION}; removing ${to_delete} oldest backup(s)" + for ((i = 0; i < to_delete; i++)); do + # Never delete the archive we just created, regardless of clock skew. + [[ "${existing[$i]}" == "$archive_path" ]] && continue + log "Removing old backup: ${existing[$i]}" + rm -f -- "${existing[$i]}" + done +fi + +log "Backup complete: ${archive_name}" From b6e06e00f5f69166f88dbad28a011b29a1c3fbd2 Mon Sep 17 00:00:00 2001 From: johnseth97 <17620345+johnseth97@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:06:44 -0400 Subject: [PATCH 6/7] feat: add systemd deployment units abiotic-factor.service runs docker compose up in the foreground so systemd directly supervises it as the main PID, with a separate ExecStop for graceful shutdown. Includes an install script and an optional, not auto-enabled, daily backup timer. --- deploy/systemd/abiotic-factor-backup.service | 16 ++++++ deploy/systemd/abiotic-factor-backup.timer | 12 +++++ deploy/systemd/abiotic-factor.service | 30 +++++++++++ scripts/install-systemd.sh | 53 ++++++++++++++++++++ 4 files changed, 111 insertions(+) create mode 100644 deploy/systemd/abiotic-factor-backup.service create mode 100644 deploy/systemd/abiotic-factor-backup.timer create mode 100644 deploy/systemd/abiotic-factor.service create mode 100755 scripts/install-systemd.sh diff --git a/deploy/systemd/abiotic-factor-backup.service b/deploy/systemd/abiotic-factor-backup.service new file mode 100644 index 0000000..a638221 --- /dev/null +++ b/deploy/systemd/abiotic-factor-backup.service @@ -0,0 +1,16 @@ +[Unit] +Description=Abiotic Factor Dedicated Server backup +Requires=docker.service +After=docker.service + +[Service] +Type=oneshot +WorkingDirectory=/srv/abiotic-factor/repo +Environment=RUNTIME_DIR=/srv/abiotic-factor/runtime +EnvironmentFile=/srv/abiotic-factor/.env +ExecStart=/srv/abiotic-factor/repo/scripts/backup.sh + +# No explicit overlap guard is needed beyond this: systemd refuses to start +# a Type=oneshot service that is already active, so if a backup is still +# running when the timer fires again, the new activation is a harmless +# no-op logged as "already in progress" rather than a second concurrent run. diff --git a/deploy/systemd/abiotic-factor-backup.timer b/deploy/systemd/abiotic-factor-backup.timer new file mode 100644 index 0000000..82b0fd5 --- /dev/null +++ b/deploy/systemd/abiotic-factor-backup.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Daily Abiotic Factor Dedicated Server backup + +[Timer] +OnCalendar=*-*-* 04:00:00 +# Spread real-world load if this pattern is copied to multiple hosts/stacks, +# and avoid every backup hitting disk at exactly the same wall-clock second. +RandomizedDelaySec=1800 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/deploy/systemd/abiotic-factor.service b/deploy/systemd/abiotic-factor.service new file mode 100644 index 0000000..52c70b1 --- /dev/null +++ b/deploy/systemd/abiotic-factor.service @@ -0,0 +1,30 @@ +[Unit] +Description=Abiotic Factor Dedicated Server (Docker Compose) +Documentation=https://github.com/johnseth97/abiotic-factor-linux-docker +Requires=docker.service +After=docker.service network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/srv/abiotic-factor/repo +Environment=RUNTIME_DIR=/srv/abiotic-factor/runtime +EnvironmentFile=/srv/abiotic-factor/.env + +# docker compose up runs in the foreground (no -d) so systemd directly +# supervises it as the main PID; compose's own restart policy is "no" +# (see docker-compose.yml) precisely so it never fights this unit's +# Restart= setting. --build picks up local Dockerfile changes on the next +# start without a separate manual build step. +ExecStartPre=/usr/bin/docker compose config --quiet +ExecStart=/usr/bin/docker compose up --build --remove-orphans +ExecStop=/usr/bin/docker compose down --timeout 120 + +Restart=on-failure +RestartSec=15 +TimeoutStartSec=0 +TimeoutStopSec=150 +KillMode=mixed + +[Install] +WantedBy=multi-user.target diff --git a/scripts/install-systemd.sh b/scripts/install-systemd.sh new file mode 100755 index 0000000..791e378 --- /dev/null +++ b/scripts/install-systemd.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Installs the abiotic-factor systemd unit. Does not start or enable the +# backup timer - see docs/operations.md for that (opt-in, documented +# separately per this project's safety constraints). +set -Eeuo pipefail + +log() { printf '[%(%Y-%m-%dT%H:%M:%SZ)T] [install-systemd] %s\n' -1 "$1" >&2; } +fatal() { log "ERROR: $1"; exit 1; } + +[[ "$(id -u)" == "0" ]] || fatal "This script must be run as root (sudo scripts/install-systemd.sh)" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +UNIT_SRC="${REPO_DIR}/deploy/systemd/abiotic-factor.service" +UNIT_DEST="/etc/systemd/system/abiotic-factor.service" + +START_NOW="false" +for arg in "$@"; do + case "$arg" in + --start) START_NOW="true" ;; + *) fatal "Unknown argument: ${arg}" ;; + esac +done + +[[ -f "$UNIT_SRC" ]] || fatal "Expected unit file not found: ${UNIT_SRC}" +[[ -f "${REPO_DIR}/docker-compose.yml" ]] || fatal "Expected ${REPO_DIR}/docker-compose.yml not found; run this from the cloned repo" +[[ -f "/srv/abiotic-factor/.env" ]] || log "WARNING: /srv/abiotic-factor/.env not found yet; the service will fail to start until it exists" + +if grep -q '^WorkingDirectory=/srv/abiotic-factor/repo$' "$UNIT_SRC"; then + if [[ "$REPO_DIR" != "/srv/abiotic-factor/repo" ]]; then + fatal "Unit file assumes WorkingDirectory=/srv/abiotic-factor/repo but this repo is at ${REPO_DIR}. Deploy from /srv/abiotic-factor/repo or edit the unit." + fi +else + fatal "Unexpected content in ${UNIT_SRC}; refusing to install" +fi + +log "Installing ${UNIT_SRC} -> ${UNIT_DEST}" +install -m 0644 "$UNIT_SRC" "$UNIT_DEST" + +log "Running systemctl daemon-reload" +systemctl daemon-reload + +log "Enabling abiotic-factor.service (not starting unless --start was passed)" +systemctl enable abiotic-factor.service + +if [[ "$START_NOW" == "true" ]]; then + log "Starting abiotic-factor.service (--start was passed)" + systemctl start abiotic-factor.service +else + log "Not starting the service. Start it with: sudo systemctl start abiotic-factor.service" +fi + +log "Done. Check status with: sudo systemctl status abiotic-factor.service" From 1652181f07346409169925d502f512372996598c Mon Sep 17 00:00:00 2001 From: johnseth97 <17620345+johnseth97@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:06:44 -0400 Subject: [PATCH 7/7] docs: rewrite README and add upstream review, operations, troubleshooting docs/upstream-review.md records every open/closed upstream issue and PR reviewed, whether it was adopted, and why. docs/operations.md and docs/troubleshooting.md cover the confirmed on-disk layout, update strategy, the SELinux Z-label requirement, and known failure modes. --- README.md | 295 ++++++++++++++++++++++++++++++++++++---- docs/operations.md | 165 ++++++++++++++++++++++ docs/troubleshooting.md | 151 ++++++++++++++++++++ docs/upstream-review.md | 91 +++++++++++++ 4 files changed, 678 insertions(+), 24 deletions(-) create mode 100644 docs/operations.md create mode 100644 docs/troubleshooting.md create mode 100644 docs/upstream-review.md diff --git a/README.md b/README.md index 995fe14..ce15247 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,280 @@ # Abiotic Factor Linux Docker -For operating a dedicated server in Docker in order to use it under Linux. -The container uses Wine to run the server under Linux. -## Setup -1. Create a new empty directory in any location with enough storage space. -2. Create a file named `docker-compose.yml` and copy the content of [`docker-compose.yml.example`](docker-compose.yml.example) into it. -3. In the `docker-compose.yml` file, the environment variables `ServerPassword` and `SteamServerName` should be adjusted. -4. Setup and start via docker-compose by running the command `docker-compose up -d`. - * This will run the server in the background and autostart it whenever the docker daemon starts. If you do not want this, remove `-d` from the command above. - * This will download the Dedicated Server binaries and game files to the `gamefiles` directory. - * Persistent save file data will be written to the `data` directory. +A hardened, self-hosted Abiotic Factor dedicated server (Steam App ID +`2857200`) running under Wine in Docker, supervised by systemd on Fedora. -## Update -There are two ways to update the game server: +## Project status and upstream attribution -1. By setting the `AutoUpdate` environment variable to `true`. This checks for updates every time the container is started. -2. By deleting the `gamefiles` directory while the server is turned off. +This is a privately maintained fork of +[Pleut/abiotic-factor-linux-docker](https://github.com/Pleut/abiotic-factor-linux-docker), +modernized and hardened for a specific home deployment. See +[`docs/upstream-review.md`](docs/upstream-review.md) for a full accounting of +which upstream issues and pull requests were reviewed, which fixes were +adopted (and how they were changed), and which were rejected and why. -### Updating the container -Sometimes, changes to this container image are necessary. To apply these: +Notable differences from upstream: -1. Merge the content of `docker-compose.yml` with any changes made from [`docker-compose.yml.example`](docker-compose.yml.example). -2. Run `docker-compose pull` to download an updated version of the container image. +* The game server runs as an unprivileged user, not root (upstream + [issue #18](https://github.com/Pleut/abiotic-factor-linux-docker/issues/18)). +* SteamCMD is installed from Valve's official tarball rather than Ubuntu's + `steamcmd` package, which was found to fail outright in testing (see + upstream review). +* Updates are verified against the installed Steam build ID rather than + trusting SteamCMD's exit code, and never delete an existing installation + before the replacement is verified. +* Graceful shutdown, a real health check, backups, and a systemd unit are + new; none of these existed upstream. -## Configuration -An example configuration for docker-compose can be found in the `docker-compose.yml` file. -In addition to the default settings, which can be set via the environment variables, further arguments can be specified via the `AdditionalArgs` environment variable. +## Architecture -Possible launch parameters and further information on the dedicated servers for Abiotic Factor can be found [here](https://github.com/DFJacob/AbioticFactorDedicatedServer/wiki/Technical-%E2%80%90-Launch-Parameters). +``` +systemd (abiotic-factor.service) + -> docker compose up (foreground, no -d) + -> container: tini -> entrypoint.sh (root, briefly) + -> gosu abiotic: SteamCMD (installs/updates App 2857200, Windows platform) + -> gosu abiotic: Wine -> AbioticFactorServer-Win64-Shipping.exe +``` -## Credits -Thanks to @sirwillis92 for finding a solution to the startup problem with the `LogOnline: Warning: OSS: Async task 'FOnlineAsyncTaskSteamCreateServer bWasSuccessful: 0' failed in 15` message. \ No newline at end of file +Abiotic Factor's dedicated server is a **Windows-only** Steam Tool (confirmed +directly against the Steam depot: `oslist: windows`) - there is no native +Linux build, so it runs under Wine. SteamCMD is forced to the Windows +platform (`@sSteamCmdForcePlatformType windows`) when installing/updating. + +## Fedora installation + +Prerequisites: Docker Engine + the `docker compose` plugin, the current user +in the `docker` group, `git`, and `gh` (GitHub CLI) if you'll be pulling +future updates via git. + +```sh +sudo mkdir -p /srv/abiotic-factor +sudo chown "$USER":"$USER" /srv/abiotic-factor +git clone /srv/abiotic-factor/repo +cd /srv/abiotic-factor/repo +cp .env.example /srv/abiotic-factor/.env +"$EDITOR" /srv/abiotic-factor/.env # set SERVER_PASSWORD at minimum +``` + +### SELinux + +Fedora ships with SELinux in `Enforcing` mode by default - **do not disable +it**. `docker-compose.yml` bind-mounts `runtime/` with the `:Z` option, +which tells Docker to relabel those directories to the private +`container_file_t` type before the container starts. This was not optional +in testing: without it, SteamCMD silently failed to persist any installed +files to a bind-mounted directory *while still reporting success* - see +[`docs/upstream-review.md`](docs/upstream-review.md) and +[`docs/troubleshooting.md`](docs/troubleshooting.md). + +### firewalld + +Check your zone before assuming you need new rules: + +```sh +firewall-cmd --get-active-zones +firewall-cmd --list-ports +``` + +On the reference host this was deployed to, the active zone already allows +`1025-65535/udp` and `1025-65535/tcp`, which covers the default game +(`7777/udp`) and query (`27015/udp`) ports. If your firewalld configuration +is narrower, open exactly the two UDP ports you configured, nothing more: + +```sh +sudo firewall-cmd --permanent --add-port=7777/udp +sudo firewall-cmd --permanent --add-port=27015/udp +sudo firewall-cmd --reload +``` + +This project does not run those commands for you and does not touch router +port forwarding - that's a decision for you to make deliberately. + +## Directory layout + +``` +/srv/abiotic-factor/ +├── repo/ # this git checkout +├── runtime/ +│ ├── gamefiles/ # Steam-installed server files (SteamCMD can redownload these) +│ ├── data/ # saves - mounted at AbioticFactor/Saved inside the container +│ ├── config/ # user-editable config - mounted at AbioticFactor/Saved/Config +│ ├── logs/ # entrypoint/SteamCMD operational logs (not the game's own logs) +│ └── backups/ # timestamped tar.gz backups, see below +└── .env # secrets and deployment config - never committed +``` + +`runtime/data` and `runtime/config` are bind-mounted at paths *nested inside* +`runtime/gamefiles`'s mount point (`AbioticFactor/Saved` and +`AbioticFactor/Saved/Config` respectively) because that's where the engine +itself expects them - Docker supports nesting bind mounts this way and it +keeps saves/config administratively separate from redownloadable game +binaries. + +## .env setup + +Copy `.env.example` to `/srv/abiotic-factor/.env` and edit it. It is +git-ignored and must never be committed. `SERVER_PASSWORD` has no working +default - the container refuses to start if it's still set to the shipped +placeholder `replace-me`. Set a real password, or set it to an explicitly +empty value for an open server. See inline comments in `.env.example` for +every other variable. + +## First launch + +```sh +cd /srv/abiotic-factor/repo +docker compose --env-file /srv/abiotic-factor/.env build +docker compose --env-file /srv/abiotic-factor/.env up +``` + +First launch installs the full game server via SteamCMD (a few GB) and +initializes a Wine prefix, which can take several minutes - this is why the +health check's `start_period` is generous (10 minutes). Once you're satisfied +it works, stop it with Ctrl-C and switch to the systemd-managed deployment +below rather than running it in a foreground terminal long-term. + +## Logs + +* Container/game stdout: `docker compose logs -f abiotic-factor` or, once + running under systemd, `journalctl -u abiotic-factor.service -f`. +* Entrypoint/SteamCMD operational logs (build IDs, update attempts, retry + history): `runtime/logs/`. +* The game engine's own internal logs live inside the save tree at + `runtime/data/Logs/` (part of Unreal Engine's standard `Saved/Logs` + location). + +## Manual update + +With `AUTO_UPDATE=true` (the default), the server checks the latest build ID +on every start and updates automatically when it doesn't match what's +installed - see "Automated update behavior" below. To force a manual update +without changing `.env`: + +```sh +cd /srv/abiotic-factor/repo +sudo systemctl stop abiotic-factor.service +AUTO_UPDATE=true docker compose --env-file /srv/abiotic-factor/.env run --rm abiotic-factor +# or simply: +sudo systemctl start abiotic-factor.service # AUTO_UPDATE=true in .env already checks on every start +``` + +## Automated update behavior + +On every start, the entrypoint: + +1. Reads the currently installed build ID from Steam's own manifest file. +2. If `AUTO_UPDATE=true`, queries the latest build ID on `STEAM_BRANCH` + (default `public`) - a lightweight metadata-only call, no download. +3. If they already match, skips the update entirely (no redownload) and, if + `VALIDATE_ON_START=true`, runs a fast in-place `validate` pass that only + redownloads files that fail a checksum. +4. If they don't match (or no valid install exists), stages a full + install/update in a separate temporary directory, verifies the expected + executable is present there, and only then copies it into the live + `gamefiles` directory - save data is never touched by this step. +5. SteamCMD failures are retried up to `STEAMCMD_RETRIES` times with + exponential backoff. An exit code of 0 is never enough by itself for this + entrypoint to call an update successful - the executable's actual presence + is always checked too. + +See [`docs/upstream-review.md`](docs/upstream-review.md) for the specific +upstream bugs (silently stale updates, `state is 0x6` failures, transient +"Missing configuration" errors) this behavior addresses, several of which +were independently reproduced against the live Steam depot while building +this. + +## Backup and restore + +```sh +# Manual backup (also stops/restarts abiotic-factor.service for consistency; +# see docs/operations.md for why there's no live-save alternative): +sudo /srv/abiotic-factor/repo/scripts/backup.sh +``` + +Backups are written to `runtime/backups/abiotic-factor-.tar.gz`, +contain `data/` and `config/` only (not redownloadable game binaries), are +verified readable immediately after creation, and are pruned to the newest +`BACKUP_RETENTION` (default 14) - the newest backup is never deleted by +pruning. An optional daily timer is provided but not enabled by default; see +[`docs/operations.md`](docs/operations.md). + +**Restore** (always with the service stopped first): + +```sh +sudo systemctl stop abiotic-factor.service +cd /srv/abiotic-factor/runtime +sudo mv data data.bak-$(date -u +%Y%m%dT%H%M%SZ) # keep the old state until you're sure +sudo mv config config.bak-$(date -u +%Y%m%dT%H%M%SZ) +sudo tar -xzf backups/abiotic-factor-.tar.gz +sudo chown -R 10000:10000 data config # match your PUID/PGID from .env +sudo systemctl start abiotic-factor.service +``` + +## systemd commands + +```sh +sudo scripts/install-systemd.sh # install + enable, does not start +sudo scripts/install-systemd.sh --start # install + enable + start + +sudo systemctl status abiotic-factor +sudo systemctl start abiotic-factor +sudo systemctl stop abiotic-factor +sudo systemctl restart abiotic-factor +journalctl -u abiotic-factor -f +``` + +## Troubleshooting + +See [`docs/troubleshooting.md`](docs/troubleshooting.md) for: server/client +version mismatch, SteamCMD update failures, missing executable, SELinux +denials, permissions, ports, and Wine crashes. + +## Upgrade procedure + +```sh +cd /srv/abiotic-factor/repo +git fetch origin +git log HEAD..origin/main --oneline # review what's changing +sudo /srv/abiotic-factor/repo/scripts/backup.sh +sudo systemctl stop abiotic-factor.service +git merge --ff-only origin/main +sudo systemctl daemon-reload # in case the unit file changed +sudo systemctl start abiotic-factor.service +``` + +## Rollback procedure + +Code/image rollback: + +```sh +cd /srv/abiotic-factor/repo +sudo systemctl stop abiotic-factor.service +git log --oneline -10 # find the last known-good commit +git checkout +sudo systemctl start abiotic-factor.service # rebuilds the image via --build +``` + +Data rollback: restore the desired `runtime/backups/*.tar.gz` per the +restore procedure above. + +## Security limitations + +* The game server process runs unprivileged (configurable `PUID`/`PGID`, + default `10000:10000`), the container is not privileged, mounts the Docker + socket to nothing, drops all Linux capabilities, and sets + `no-new-privileges:true`. See `docker-compose.yml`. +* SteamCMD authenticates anonymously; no Steam account credentials are used + or stored by this project. +* There is no RCON, admin console, or authenticated management API exposed + by this container - the `-AdminPassword` launch parameter, if set, is + handled entirely by the game itself. +* This project does not audit Wine or the closed-source game server binary + itself for vulnerabilities; both run with whatever security properties + Wine and Valve/the game developer provide. + +**Anyone with Docker access or `sudo` on this host is effectively a host +administrator.** Docker's default configuration allows containers to be +started with capabilities and mounts (including bind-mounting arbitrary host +paths) that can trivially escalate to full host root. Do not grant Docker +group membership or `sudo` to anyone you would not trust with root on this +machine, regardless of what this project's own compose file restricts. diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..7e1ce4c --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,165 @@ +# Operations + +Deeper detail than the README for day-to-day operation of this deployment. + +## Confirmed on-disk layout (verified against a real install) + +Verified directly against the live Steam depot for App ID `2857200` while +building this (see `docs/upstream-review.md`); do not assume this is still +correct after a major game update without re-checking. + +``` +/ +├── AbioticFactorServer.exe # Steam's declared launch stub +├── AbioticFactor/ +│ ├── Binaries/Win64/ +│ │ └── AbioticFactorServer-Win64-Shipping.exe # actual binary this entrypoint runs +│ ├── Content/Paks/... +│ └── Saved/ # <- bind-mounted separately (runtime/data) +│ └── Config/ # <- bind-mounted separately (runtime/config) +├── Engine/... +└── steamapps/appmanifest_2857200.acf # source of truth for installed build id +``` + +The entrypoint runs `AbioticFactor/Binaries/Win64/AbioticFactorServer-Win64-Shipping.exe` +directly rather than the root `AbioticFactorServer.exe` stub, matching what +the upstream community has run in production successfully; we did not find +documentation confirming the root stub behaves identically when launched +outside a real Steam client context (it likely exists mainly for +Steamworks-mediated launches), so bypassing it directly is the +better-verified path. + +## Update strategy in detail + +Directory swap was the obvious first design for "stage, verify, promote" - +and was rejected. `runtime/data` and `runtime/config` are bind-mounted at +paths *nested inside* `gamefiles` (`AbioticFactor/Saved` and +`AbioticFactor/Saved/Config`), because that's where the engine expects them. +Renaming or replacing the parent directory out from under active bind mounts +is not something this project is willing to rely on for a process holding +live save data. + +Instead: + +1. A full install/update is staged into `/data/gamefiles.staging` inside the + container - a path that is never bind-mounted and has no relationship to + the live save data. +2. The staged copy is verified (executable present, non-empty). +3. `rsync -a --delete --exclude AbioticFactor/Saved` copies the staged + content into the live `gamefiles` directory, explicitly excluding the + Saved tree. +4. The live install is re-verified after the copy. + +This is not fully atomic (an interrupted rsync could leave a partial mix of +old and new files), which is a real, accepted trade-off against the +alternative of ever touching save data during a directory swap. If an update +is interrupted mid-`rsync`, the fix is to simply re-run an update (`AUTO_UPDATE=true` +start, or manually per the README) - SteamCMD's own `validate` pass and this +script's executable-presence check will detect and correct an inconsistent +`gamefiles` directory; saves are never at risk because they live in a +separate bind mount rsync never touches. + +The common "nothing changed" restart case does **not** restage or +redownload anything: the installed build ID (read from +`steamapps/appmanifest_2857200.acf`) is compared against the latest build ID +on the configured branch (a metadata-only SteamCMD call) before any staging +happens. + +## SELinux + +Confirmed directly on this deployment (Fedora 43, `getenforce` = `Enforcing`): +without `:Z` on the bind mounts in `docker-compose.yml`, SteamCMD reported +`Success! App '2857200' fully installed` while the host-side directory +remained completely empty - a silent SELinux denial, not a SteamCMD bug. +Adding `:Z` (Docker relabels the mounted directories to the private +`container_file_t` type before start) fixed it immediately and was verified +with a full real install end to end. + +`:Z` (private/exclusive label) rather than `:z` (shared label) was chosen +because `runtime/` is used exclusively by this compose stack, not shared +with any other container. + +No custom SELinux policy module or `setenforce 0` was used or is needed. + +## firewalld + +See the README's firewalld section. This project does not modify firewalld +rules automatically. If you need to open ports, the exact commands are +documented there - review and run them yourself. + +## Health check + +`healthcheck/query.py` sends a Source-engine-style `A2S_INFO` UDP query to +`QUERY_PORT` on localhost inside the container. A well-formed reply is +"healthy". This proves the game process is alive, has bound the query port, +and answers the same query Steam's server browser uses. It does **not** +prove saves loaded correctly, that any specific player can reach the server +past your network's NAT/firewall, or anything about in-game logic. A +`start_period` of 10 minutes accounts for first-boot SteamCMD install + +Wine prefix initialization; steady-state restarts are much faster. + +## Backups + +`scripts/backup.sh`: + +* Stops `abiotic-factor.service` before archiving (there is no documented + live-save/RCON command for this server - see `docs/upstream-review.md` - + so a live archive could capture a partially-written save) and restarts it + afterward if it was running. +* Archives `runtime/data` and `runtime/config` only; `runtime/gamefiles` is + excluded since SteamCMD can redownload it. +* Verifies the resulting `.tar.gz` with `tar -tzf` before trusting it. +* Prunes to `BACKUP_RETENTION` (default 14) oldest-first; the archive just + created is never pruned. +* Can be run manually, or via the optional systemd timer below. + +### Enabling the daily backup timer (opt-in) + +Not enabled by `scripts/install-systemd.sh`. To enable it deliberately: + +```sh +sudo cp deploy/systemd/abiotic-factor-backup.service deploy/systemd/abiotic-factor-backup.timer /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now abiotic-factor-backup.timer +systemctl list-timers abiotic-factor-backup.timer +``` + +Default schedule: daily at approximately 04:00 local time, with up to a +30-minute randomized delay and `Persistent=true` (a missed run due to the +host being off executes on next boot). + +## Shutdown timing during SteamCMD install/update + +`entrypoint.sh`'s SIGTERM/SIGINT trap can only act between commands - if a +shutdown signal arrives while a synchronous, foreground `steamcmd` call is +running (initial install or an update), bash defers running the trap until +that call returns, which on a slow connection can be longer than +`stop_grace_period`/`TimeoutStopSec`. In that case Docker/systemd will +eventually force-kill the container. This is considered acceptable: a +SteamCMD run that gets killed this way only ever affects the disposable +`gamefiles.staging` directory (see "Update strategy" above), never live save +data, and the next start simply retries the install/update from scratch. + +## systemd signal handling notes + +`abiotic-factor.service` runs `docker compose up --build --remove-orphans` +in the foreground (`Type=simple`, no `-d`) specifically so systemd directly +supervises that process as the unit's main PID, and never uses +`docker compose up -d` with `Type=simple` (which would make systemd think +the unit started successfully the instant the detached CLI exited, with no +real supervision of the actual containers). + +`ExecStop=docker compose down --timeout 120` is what actually asks the +container to stop gracefully; the foreground `docker compose up` process +then exits on its own once its containers are gone. `KillMode=mixed` was +chosen over the more aggressive `control-group` so that, if the `docker +compose` CLI process itself hangs, systemd's fallback `SIGKILL` targets it +without indiscriminately blasting signals at everything in its cgroup - note +that the actual game container runs under dockerd's own cgroup hierarchy, +not nested under this systemd unit's cgroup, so `ExecStop`'s `docker compose +down` remains the real mechanism controlling the container's shutdown +regardless of `KillMode`. + +This was verified empirically (not just reasoned about) during testing - see +the "systemd tests" section of the PR this file shipped with for the actual +`systemctl start/stop/restart` transitions exercised and their outcomes. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..65d0d19 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,151 @@ +# Troubleshooting + +## Server/client version mismatch ("Outdated Server"/"Outdated Client") + +Historically the most common upstream complaint +([#24](https://github.com/Pleut/abiotic-factor-linux-docker/issues/24), +[#27](https://github.com/Pleut/abiotic-factor-linux-docker/issues/27), +[#8](https://github.com/Pleut/abiotic-factor-linux-docker/issues/8)). Check: + +```sh +journalctl -u abiotic-factor -n 200 --no-pager | grep -i "build id" +``` + +Compare the logged "Installed build id" against the latest build id also +logged on startup. If they differ and `AUTO_UPDATE=true`, an update should +have been staged automatically - check `runtime/logs/steamcmd-*.log` for the +most recent attempt's outcome. If updates are consistently failing, see +"SteamCMD update failure" below. + +## SteamCMD update failure + +Two known-real transient failure modes, both reproduced directly against +Steam while building this image (see `docs/upstream-review.md`): + +* `ERROR! Failed to install app '2857200' (Missing configuration)` - + happened in roughly 1 in 3 fresh attempts in our testing. The entrypoint + retries up to `STEAMCMD_RETRIES` times with exponential backoff + specifically because of this. If it still fails after all retries, it's + likely a genuine network problem, not this specific bug - check outbound + connectivity to Steam's CDN. +* `Error! App '2857200' state is 0x6 after update job` - a corrupted local + depot cache state. The entrypoint's staged-update path effectively works + around this by never updating in a directory with pre-existing (possibly + corrupted) SteamCMD state for a full reinstall. +* SteamCMD's *own* self-update bootstrap (separate from the game depot + itself) can get stuck: on first run in a fresh container it downloads its + own `steamcmd_public_all` update package, and if that download is + truncated/corrupted (`Fatal Error: Download of package + (steamcmd_public_all) failed after 0 bytes`), every subsequent attempt + re-fails the same "pending update" instead of retrying cleanly - visible + in `~/Steam/logs/bootstrap_log.txt` as a repeating + `Download failed: http error 0` / `Found pending update` loop. This is + entirely SteamCMD's own bootstrap cache under `$HOME/Steam`, unrelated to + the game depot or any of `runtime/`. The entrypoint detects this pattern + in the SteamCMD log and clears `$HOME/Steam` before the next retry, + forcing a clean self-update rather than compounding the corrupted state. + +Check `runtime/logs/steamcmd--attempt.log` for the full output +of any given attempt. + +## Missing executable + +``` +Cannot start: /data/gamefiles/AbioticFactor/Binaries/Win64/AbioticFactorServer-Win64-Shipping.exe is missing +``` + +Means every install/update attempt failed verification. Check +`runtime/logs/` for the SteamCMD attempt logs. Do not manually delete +`runtime/gamefiles` to "force a clean install" as a first step - restarting +the container with `AUTO_UPDATE=true` (default) will already re-stage a full +install into a separate temp directory without touching `runtime/data`. + +## SELinux denial + +**Symptom:** SteamCMD logs `Success! App '2857200' fully installed`, but +`runtime/gamefiles` remains empty or incomplete on the host, and the +container loops trying to "install" forever. + +This is not a SteamCMD bug - it's SELinux silently denying writes into a +bind-mounted directory that hasn't been labeled for container access. +Confirmed directly on this deployment. Check: + +```sh +getenforce # should be "Enforcing" - do not turn this off +sudo ausearch -m avc -ts recent | grep -i docker +``` + +Fix: confirm `docker-compose.yml`'s volumes all have the `:Z` suffix (they +do by default in this repo - if you've modified them, put it back), then +recreate the container so Docker relabels the directories: + +```sh +docker compose down +docker compose up +``` + +Never fix this with `setenforce 0`. + +## Permissions + +**Symptom:** entrypoint fails with `... is not writable by abiotic (PUID:PGID)`. + +Usually means `runtime/` was created with different ownership than +`PUID`/`PGID` in `.env` (for example, created manually with `sudo mkdir` +before the container ever ran, or `PUID`/`PGID` were changed after first +run). Fix: + +```sh +sudo chown -R "$PUID":"$PGID" /srv/abiotic-factor/runtime +``` + +Where `$PUID`/`$PGID` match your `.env`. The entrypoint fixes ownership of +directories it manages automatically on every start where it detects a +mismatch, but it cannot do this for directories it fundamentally cannot +write to at all. + +## Ports + +* Confirm both `GAME_PORT` and `QUERY_PORT` match on host and container + sides - `docker-compose.yml` maps them 1:1 from `.env`, but if you change + one without the other, the server may show in your LAN list but not + respond to Steam server-browser queries (this exact symptom was reported + upstream in + [#6](https://github.com/Pleut/abiotic-factor-linux-docker/issues/6) and + traced to a host/container port mismatch, not a container bug). +* Both ports are UDP only; nothing here uses TCP. +* This project does not configure router port forwarding for you - if + players outside your LAN can't connect but LAN clients can, that's a + router/NAT configuration question, not a container issue. + +## Wine crashes / warnings + +* `0148:err:ole:com_get_class_object apartment not initialised` - a benign, + commonly seen Wine/COM warning + ([#7](https://github.com/Pleut/abiotic-factor-linux-docker/issues/7)); not + correlated with actual failures in testing. Safe to ignore. +* Server exits shortly after start with + `LogCore: Engine exit requested (reason: Win RequestExit)` in the logs and + no other obvious error - in the one upstream report we reviewed + ([#3](https://github.com/Pleut/abiotic-factor-linux-docker/issues/3)) this + correlated with constrained VM resources, not a container bug. Check + available memory/CPU on the host before assuming Wine itself is broken. +* If Wine fails to start at all with a display/X11-related error: this + project does not run an X server (Xvfb) in the container. The upstream + project has run headless without one; if your environment specifically + requires it, that's a deviation from the tested configuration and should + be investigated (and documented) separately rather than worked around + silently. + +## No documented graceful save/shutdown command + +There is no confirmed in-game console command, RCON interface, or documented +"save and exit" command for this dedicated server (checked the official +[launch parameters wiki](https://github.com/DFJacob/AbioticFactorDedicatedServer/wiki/Technical-%E2%80%90-Launch-Parameters) +and did not find one). This project does not invent one. Shutdown is handled +by forwarding `SIGTERM` to the Wine process and giving the engine's own exit +handling time to run (`SHUTDOWN_TIMEOUT`, default 60s) before escalating to +`SIGKILL`. If you observe save corruption specifically correlated with +container shutdown despite this, that's worth its own upstream bug report - +please don't work around it by shortening the shutdown timeout, which would +make it worse. diff --git a/docs/upstream-review.md b/docs/upstream-review.md new file mode 100644 index 0000000..c46897b --- /dev/null +++ b/docs/upstream-review.md @@ -0,0 +1,91 @@ +# Upstream Review + +Review of `Pleut/abiotic-factor-linux-docker` open/closed issues and PRs performed on +2026-07-14 before starting the modernization work on `feat/modernize-and-systemd`. +Upstream `main` at the time of review: `6f3bd0e` ("Adjustment image name"). + +## Pull requests + +| # | Title | State | Adopted? | Notes | +|---|-------|-------|----------|-------| +| [#26](https://github.com/Pleut/abiotic-factor-linux-docker/pull/26) | Retry steamcmd install up to 5 times if server executable is not found | Open | **Adopted (reimplemented)** | The naive `for attempt in $(seq 1 5)` loop with no delay between attempts matches a real bug we reproduced directly against Steam (`ERROR! Failed to install app '2857200' (Missing configuration)`, transient, happens on a fresh anonymous login roughly 1 in 3 attempts). We kept the core idea — detect a missing/incomplete install and retry — but rewrote it with bounded exponential backoff (`STEAMCMD_RETRIES`, doubling delay), explicit distinction between "SteamCMD exited nonzero" and "exited zero but files are missing," and structured logging instead of a bare `echo`. | +| [#25](https://github.com/Pleut/abiotic-factor-linux-docker/pull/25) | Add locales en-US.UTF-8 to prevent unexpected warnings | Open | **Adopted** | Small, low-risk, addresses a real cosmetic/locale warning from SteamCMD/Wine. Folded into the new Dockerfile's package install layer. | +| [#23](https://github.com/Pleut/abiotic-factor-linux-docker/pull/23) | Handle state is 0x6 when updating server binaries | Closed, not merged | **Adopted (reimplemented, safer)** | Confirms a real Steam-side failure mode (`Error! App '2857200' state is 0x6 after update job`) that requires wiping and re-downloading. The submitted patch runs `find /server/* -not -path ".../Saved*" -delete` directly against the live install directory — a single wrong path segment would delete an in-place save. We instead stage updates into a separate temp directory and only ever delete/replace the *staged* copy, promoting it into `gamefiles` only after the new executable is verified present. This also generalizes to any "update reported success but files are missing/corrupt" case, not just 0x6 specifically. | +| [#14](https://github.com/Pleut/abiotic-factor-linux-docker/pull/14) | Added Discord Webhook Integration for Session Code | Open | **Rejected** | Out of scope for a hardening/modernization pass — it's a feature addition, not a fix. It also repoints the compose example at an unrelated personal Docker Hub image (`thisismynameok/...`) and bumps the base image to `ubuntu:24.04` as a side effect of an unrelated change. Users who want Discord integration for invite codes can watch the log file/health check output externally; not reimplemented here. | +| [#13](https://github.com/Pleut/abiotic-factor-linux-docker/pull/13), [#12](https://github.com/Pleut/abiotic-factor-linux-docker/pull/12) | Discord Webhook Integration for Invite/Session Code | Closed, not merged | **Rejected** | Same rationale as #14; earlier duplicate attempts at the same feature. | +| [#2](https://github.com/Pleut/abiotic-factor-linux-docker/pull/2) | Refactor docker workflow | Merged | N/A | Already part of upstream `main`; nothing further to do. | + +## Issues + +| # | Title | State | Relevant to this work? | Disposition | +|---|-------|-------|------------------------|-------------| +| [#27](https://github.com/Pleut/abiotic-factor-linux-docker/issues/27) | No longer updates to the latest version | Open | Yes | Root cause is that `AutoUpdate=true` trusts SteamCMD's exit code and never verifies the installed build ID or executable against the current depot. New entrypoint logs installed vs. latest build ID and treats "exited zero but executable missing/stale" as a failure, not a success. | +| [#24](https://github.com/Pleut/abiotic-factor-linux-docker/issues/24) | "Outdated Server" error on clients after patch 1.1 | Closed | Yes | Workaround reported was "delete everything in gamefiles to force a full update" — i.e. the old `AutoUpdate` path silently no-ops when it shouldn't. Addressed by the same build-ID verification and staged-update logic as #27. | +| [#8](https://github.com/Pleut/abiotic-factor-linux-docker/issues/8) | "outdated client" error after 0.8.2 | Closed | Yes | Same family of bug as #24/#27, older instance. | +| [#22](https://github.com/Pleut/abiotic-factor-linux-docker/issues/22) | Clients receive error Hawking after Hotfix 1.0.0.21184 | Open | Yes | Same family; also the log excerpt attached to PR #23 for this app was captured from this issue's reporter. | +| [#18](https://github.com/Pleut/abiotic-factor-linux-docker/issues/18) | Server runs as root | Open | Yes | Confirmed true — the upstream `Dockerfile` has no `USER` directive at all, so SteamCMD, Wine, and the game process all run as UID 0. A comment on the issue incorrectly claims the image behaves like `GameServerManagers/docker-steamcmd` (PUID/PGID, `steam` user) — it does not; that project is unrelated and this image implements no such mechanism. Directly addressed: new image creates and runs as an unprivileged `abiotic` user with configurable `PUID`/`PGID`. | +| [#20](https://github.com/Pleut/abiotic-factor-linux-docker/issues/20) | Every restart shows server as 'corrupt', can't load from backup | Closed | Yes | Reporters worked around this by manually fixing permissions on `PlayerData`/`World` ("had to make it read-writable, then read-only again") and by restoring `PlayerData` after every restart — classic symptoms of a save write getting interrupted mid-write (hard container kill) combined with inconsistent bind-mount ownership. Addressed by: (a) graceful SIGTERM-based shutdown with a real grace period instead of the container being hard-killed, (b) directories created and owned by the configured `PUID`/`PGID` up front so permissions don't drift, (c) backups excluded from touching live save files while the server is running (stop-before-backup). | +| [#29](https://github.com/Pleut/abiotic-factor-linux-docker/issues/29), [#28](https://github.com/Pleut/abiotic-factor-linux-docker/issues/28) | Password enforced even though there isn't a password assigned | Closed (dup) | Yes | Real bug: `ServerPassword="${ServerPassword:-password}"` uses `:-`, which substitutes the default whenever the variable is **unset or empty** — so an intentionally empty password in compose still gets replaced with the literal string `password`. New entrypoint uses `${VAR-default}` (no colon) so an explicitly empty value is honored as "no password," while an *unset* variable still gets a safe default, and `SERVER_PASSWORD=replace-me` (the documented placeholder) is explicitly rejected at startup. | +| [#21](https://github.com/Pleut/abiotic-factor-linux-docker/issues/21) | Default Sandbox File not created | Open | Partially | The dedicated server does not appear to generate `Server1Sandbox.ini` on first run by itself. Not a container bug; documented in `docs/troubleshooting.md` with the `-SandboxIniPath=` launch parameter and where to place a starter file under the config bind mount. | +| [#7](https://github.com/Pleut/abiotic-factor-linux-docker/issues/7) | `0148:err:ole:com_get_class_object apartment not initialised` | Closed | Cosmetic | A benign Wine/COM warning commonly seen running Windows game servers under Wine; does not correlate with actual failures in our testing. Documented as a known-benign log line in troubleshooting rather than "fixed." | +| [#6](https://github.com/Pleut/abiotic-factor-linux-docker/issues/6) | Query port issues when running a second instance on non-default ports | Closed | Docs only | Root cause was port-forwarding/compose configuration, not a container bug. New compose file and README are explicit about the `GAME_PORT`/`QUERY_PORT` pair and the fact that both host and container sides must match for UDP.| +| [#3](https://github.com/Pleut/abiotic-factor-linux-docker/issues/3) | Server exits ~1 minute after start (`Win RequestExit`) | Closed | Informational | No container-level fix identified; looked like a resource/engine-side crash on the reporter's hardware. Kept in troubleshooting as a "check resource limits and full log" entry. Notably, the engine's own exit path (`FPlatformMisc::RequestExit` → `LogCore: Engine exit requested`) is the same path we rely on for graceful shutdown — see below. | +| [#11](https://github.com/Pleut/abiotic-factor-linux-docker/issues/11) | Can you build an arm64 version? | Open | Won't fix | The dedicated server is a Windows-only binary (`oslist: windows` in Steam depot metadata) run under Wine; Wine's Windows/x86 emulation on arm64 is not a supported or reliable combination for this workload. Documented as a known limitation. | +| #19, #17, #16, #15, #10, #9, #5, #4, #1 | Various support questions / duplicates / already resolved | Closed/Open | No | No code changes required; several are answered in the existing README or are host-specific (port forwarding, VM specs) rather than container bugs. | + +## Steam depot verification (why we did not trust the old entrypoint's paths) + +Queried directly against Steam (`steamcmd +app_info_print 2857200`) and confirmed with a real +install into a scratch directory rather than assuming the 2024-era entrypoint was still correct, +per this task's instructions: + +* App 2857200 ("Abiotic Factor Dedicated Server") is `oslist: windows`, `osarch: 64` — Windows-only, + confirming Wine is required. +* The depot's own declared launch executable is **`AbioticFactorServer.exe`** at the install-dir + root with default arguments `-log -newconsole` — not the + `AbioticFactor/Binaries/Win64/AbioticFactorServer-Win64-Shipping.exe` path the old entrypoint + invokes directly. Both paths were checked against a real install (see `docs/operations.md` for + the confirmed on-disk layout used by the new entrypoint). +* Current public branch build ID at review time: `24119152`. +* The current launch-parameter reference + ([DFJacob/AbioticFactorDedicatedServer wiki](https://github.com/DFJacob/AbioticFactorDedicatedServer/wiki/Technical-%E2%80%90-Launch-Parameters)) + documents `-DisableAsyncLoadingThread`, not `-NoAsyncLoadingThread` as the old entrypoint uses — + the old flag is silently ignored by the engine's argument parser rather than erroring, which is + why nobody noticed. The trailing `-tcp` argument in the old entrypoint is not a documented launch + parameter anywhere in current server documentation and was dropped rather than carried forward + out of caution. +* No documented in-game console command, RCON interface, or dedicated "save and shut down" command + exists for this dedicated server. We did **not** invent one. Shutdown handling instead relies on + forwarding `SIGTERM` to the Wine process tree and giving the engine's own exit-request handling + (visible in issue #3's logs as `LogCore: Engine exit requested (reason: Win RequestExit)`) enough + time to run via `stop_grace_period`/`TimeoutStopSec`. This is documented as a limitation, not + papered over. +* Reproduced the exact SteamCMD failure mode described in PR #26 + (`ERROR! Failed to install app '2857200' (Missing configuration)`) directly, several times, while + testing anonymous installs from this host — confirming it is a real, currently-occurring transient + failure and not stale/historical, which justified keeping retry logic in the new entrypoint. + +## Additional findings not covered by any existing upstream issue + +Discovered independently while building and testing this image; not currently tracked by any +upstream issue or PR, so recorded here for anyone comparing this fork against upstream: + +* **Ubuntu's `steamcmd` apt package (`0~20180105-4` on 22.04) fails outright** with + `Steamcmd needs to be online to update` even with working network access, reproduced + consistently. Switched to fetching the official tarball directly from Valve instead + (`steamcmd_linux.tar.gz`), which does not have this problem. See the Dockerfile. +* **SELinux (`Enforcing`, the Fedora default) silently blocks SteamCMD from writing to a + bind-mounted host directory that lacks the `container_file_t` label**, while SteamCMD still + reports `Success! App fully installed`. This is arguably a SteamCMD robustness bug (it doesn't + detect/report the write failures), but the practical fix on our side is Docker's `:Z` bind-mount + option. Reproduced and fixed; see `docs/operations.md`. +* **Valve's official steamcmd tarball ships `steamcmd.sh` with no execute permission for + group/other** (owned by an arbitrary build-time uid, mode `750`), so an unprivileged runtime user + can't execute it without an explicit `chmod` after extraction. +* **SteamCMD's own self-update bootstrap can get stuck in a permanently-failing loop** if its first + self-update download is truncated, independent of the actual game depot. See + `docs/troubleshooting.md`. +* **`cap_drop: ALL` in Compose also strips capabilities from the container's root user**, not just + the unprivileged runtime user - `CAP_CHOWN`/`CAP_SETUID`/`CAP_SETGID`/`CAP_DAC_OVERRIDE` all had + to be added back for the entrypoint's own root-phase bootstrap (fixing bind-mount ownership, + reconciling PUID/PGID, and gosu's privilege drop) to keep working.