Skip to content

Modernize and harden dedicated server container (SteamCMD reliability, unprivileged runtime, systemd)#1

Open
johnseth97 wants to merge 7 commits into
mainfrom
feat/modernize-and-systemd
Open

Modernize and harden dedicated server container (SteamCMD reliability, unprivileged runtime, systemd)#1
johnseth97 wants to merge 7 commits into
mainfrom
feat/modernize-and-systemd

Conversation

@johnseth97

Copy link
Copy Markdown
Owner

Summary

Modernizes and hardens this fork of Pleut/abiotic-factor-linux-docker for a private, systemd-supervised deployment: the game server no longer runs as root, SteamCMD installs/updates are retried and verified against the actual installed build ID rather than trusted on exit code alone, updates are staged and never risk save data, there's a real health check, graceful shutdown, backup/restore tooling, and a systemd unit + installer for boot-time supervision.

Verified directly against the live Steam depot for App ID 2857200 rather than assuming the old entrypoint's executable path/launch arguments still apply (they had drifted - see docs/upstream-review.md).

Upstream issues and PRs addressed

Full accounting with adopt/reject rationale in docs/upstream-review.md. Highlights:

Security changes

  • Game server, Wine, and SteamCMD all run as an unprivileged user (abiotic, configurable PUID/PGID, default 10000:10000) - never UID 0.
  • Container is not privileged, does not mount the Docker socket, drops all Linux capabilities and adds back only the four required for the entrypoint's brief root-phase bootstrap (CHOWN, SETUID, SETGID, DAC_OVERRIDE - see docker-compose.yml for why), and sets no-new-privileges:true.
  • SteamCMD authenticates anonymously; no Steam credentials are stored anywhere.
  • .env (real secrets) is git-ignored; .env.example ships only placeholders, and the entrypoint refuses to start if SERVER_PASSWORD is still the placeholder.

Update strategy

Staged, verified, never destructive - see docs/operations.md "Update strategy in detail" for the full reasoning, including why a true atomic directory swap was rejected (saves are bind-mounted at nested paths inside the install directory). Short version: install/update into a disposable staging directory, verify the executable is actually present, rsync into the live directory excluding the save tree, re-verify. Failed updates leave the previous working installation untouched.

Test evidence

  • docker compose config - clean.
  • shellcheck entrypoint.sh scripts/*.sh - clean (via koalaman/shellcheck container image, since dnf install shellcheck wasn't available without an interactive sudo password).
  • docker compose build --no-cache - succeeds; verified no secrets in image layers/history, entrypoint scripts executable, final process model runs unprivileged.
  • systemd-analyze verify on all three unit files - clean.
  • Fresh-install testing against real temporary runtime directories on the target host uncovered and fixed three real bugs before they'd have hit production:
    • Compose init: true + a baked-in tini ENTRYPOINT fighting each other (removed the redundant one).
    • cap_drop: ALL also stripping capabilities from the container's root user, breaking the entrypoint's own privilege-drop bootstrap (fixed with a minimal cap_add).
    • Valve's official steamcmd tarball shipping steamcmd.sh without execute permission for non-owner users (fixed with an explicit chmod in the Dockerfile).
  • SELinux: confirmed directly on this host that bind-mounting runtime/ without :Z causes SteamCMD to report a successful install while writing nothing to the host directory - a silent AVC denial, not a SteamCMD bug. Fixed and verified with a full real install completing end-to-end with :Z in place.

Known limitations

  • The CI workflow (.github/workflows/ci.yml) is not included in this PR yet. The gh token used to push this branch has repo/read:org/gist scopes but not workflow, which GitHub requires for any push that touches .github/workflows/*. The file is written and ready (shellcheck/compose-config/build-only, actions pinned to SHAs) - either run gh auth refresh -h github.com -s workflow and I'll push it as a follow-up commit, or add it manually via the GitHub web UI from the copy in this fork's working tree.
  • A live end-to-end install/Wine-launch verification is not yet confirmed as of this PR - testing repeatedly hit a currently-ongoing SteamCMD self-update bootstrap failure against Valve's CDN (steamcmd_public_all/steamcmd_linux packages failing checksum/truncation, independent of the actual game depot), reproduced consistently over roughly 30 minutes of testing from this network, including via manual SteamCMD invocations outside this project's code entirely. The entrypoint's retry/backoff and stuck-cache-clearing logic behave correctly and fail safely and clearly (bounded retries, distinct exit code, no partial/corrupt state left in gamefiles, no impact on save data) - see docs/upstream-review.md for the reproduction details. Re-run the fresh-install test once this clears; exact command in docs/operations.md.
  • No RCON/console save-and-shutdown command exists for this dedicated server (checked the official launch-parameters wiki); graceful shutdown relies on SIGTERM + a grace period. Documented, not papered over.
  • CI does not attempt to launch the full game server (multi-GB SteamCMD download + Wine startup is unreliable/slow in Actions); the fresh-install test is a manual/host-level step, documented in docs/operations.md.

Deployment instructions

See README.md "Fedora installation" through "systemd commands" for the full walkthrough. Short version:

sudo mkdir -p /srv/abiotic-factor && sudo chown "$USER":"$USER" /srv/abiotic-factor
git clone <fork-url> /srv/abiotic-factor/repo
cd /srv/abiotic-factor/repo
cp .env.example /srv/abiotic-factor/.env && "$EDITOR" /srv/abiotic-factor/.env
sudo scripts/install-systemd.sh --start

Rollback instructions

sudo systemctl stop abiotic-factor.service
cd /srv/abiotic-factor/repo
git checkout <previous-commit-or-tag>
sudo systemctl start abiotic-factor.service

Data rollback: restore from runtime/backups/*.tar.gz per README.md "Backup and restore" (service must be stopped first).

- 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 Pleut#25) to remove SteamCMD's
  setlocale warning.
- Fix execute permissions on the extracted steamcmd tarball, which Valve
  ships without group/other execute bits.
- 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 Pleut#28/Pleut#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 Pleut#27/Pleut#24/Pleut#8, PR Pleut#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 Pleut#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.
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.
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.
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.
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.
…ting

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant