a00bd620a1
Self-hostable game-server control panel: controller + agent + 26 game modules. One-line install (prebuilt release, no Go required): curl -fsSL https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.sh | sudo bash Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
342 lines
18 KiB
Bash
342 lines
18 KiB
Bash
#!/bin/bash
|
|
# panel-native 7DTD entrypoint.
|
|
#
|
|
# Contract:
|
|
# /game — SteamCMD-installed dedicated server (volume, populated by
|
|
# the panel's SteamCMD updater sidecar). If missing, exit
|
|
# with a helpful message telling the operator to run Update.
|
|
# /game-saves — save + config writable volume.
|
|
# $TELNET_PASSWORD — injected by the panel via a module secret; rendered
|
|
# into serverconfig.xml so telnet RCON matches.
|
|
#
|
|
# On every start we re-render serverconfig.xml from the bind-mounted
|
|
# template so operator edits to module values take effect without needing a
|
|
# Docker image rebuild. Saves and world data live in /game-saves so a steam
|
|
# validate won't ever touch them.
|
|
|
|
set -euo pipefail
|
|
|
|
log() { printf '[panel-7dtd] %s\n' "$*"; }
|
|
|
|
GAME_DIR=/game
|
|
SAVE_DIR=/game-saves
|
|
CONFIG=${SAVE_DIR}/serverconfig.xml
|
|
TEMPLATE=${SAVE_DIR}/serverconfig.xml.rendered
|
|
|
|
# Allocator-derived ports. Panel sets these via container env (manifest's
|
|
# ports[].env: + runtime.docker.env: declarations). Defaults mirror the
|
|
# canonical 7DTD numbers so single-instance setups still work without
|
|
# the panel allocator.
|
|
GAME_PORT="${GAME_PORT:-26900}"
|
|
TELNET_PORT="${TELNET_PORT:-8081}"
|
|
WEBDASH_PORT="${WEBDASH_PORT:-8080}"
|
|
|
|
# --- sanity: game files installed? ---
|
|
if [[ ! -x "${GAME_DIR}/startserver.sh" && ! -x "${GAME_DIR}/7DaysToDieServer.x86_64" ]]; then
|
|
log "ERROR: /game is empty."
|
|
log " Game files haven't been downloaded yet."
|
|
log " Run 'Update' on this server in the panel — that kicks off a"
|
|
log " SteamCMD sidecar which installs 7DTD into this volume."
|
|
exit 78 # EX_CONFIG
|
|
fi
|
|
|
|
# --- render serverconfig.xml ---
|
|
# The panel writes a pre-rendered serverconfig.xml.rendered into the save
|
|
# volume at create/update time (see module create hook). We symlink it into
|
|
# the game dir so the game binary finds it on the expected path.
|
|
if [[ -s "${TEMPLATE}" ]]; then
|
|
log "using panel-rendered config (${TEMPLATE})"
|
|
cp -f "${TEMPLATE}" "${CONFIG}"
|
|
elif [[ ! -s "${CONFIG}" ]]; then
|
|
log "no panel config found — using the shipped default in ${GAME_DIR}/serverconfig.xml"
|
|
if [[ -s "${GAME_DIR}/serverconfig.xml" ]]; then
|
|
cp -f "${GAME_DIR}/serverconfig.xml" "${CONFIG}"
|
|
else
|
|
log "ERROR: no default serverconfig.xml shipped with the game either — aborting"
|
|
exit 78
|
|
fi
|
|
fi
|
|
|
|
# Make sure the game reads OUR config (symlink from game dir → save dir).
|
|
ln -sf "${CONFIG}" "${GAME_DIR}/serverconfig.xml"
|
|
|
|
# --- auto-populate TelnetPassword if empty ---
|
|
#
|
|
# 7DTD is surprising here: if TelnetPassword="", the server *enables* telnet
|
|
# but binds it to the container's loopback only, so any host-mapped port is
|
|
# unreachable. The panel's RCON cannot connect → every "list players" call
|
|
# fails with `telnet auth: read password prompt: EOF`.
|
|
#
|
|
# Fix: on first boot, if TelnetPassword is empty we stamp a random one into
|
|
# the config + persist the plaintext to /game-saves/.panel-telnet-password
|
|
# so the Config tab can surface it to the operator. Once set, we never
|
|
# rotate it — operator owns it from then on.
|
|
PASS_FILE="${SAVE_DIR}/.panel-telnet-password"
|
|
if grep -Eq '<property name="TelnetPassword"[[:space:]]+value=""[[:space:]]*/>' "${CONFIG}" 2>/dev/null; then
|
|
if [[ -s "${PASS_FILE}" ]]; then
|
|
NEW_PW=$(cat "${PASS_FILE}")
|
|
log "using persisted TelnetPassword from ${PASS_FILE}"
|
|
else
|
|
NEW_PW=$(head -c 16 /dev/urandom | od -An -tx1 | tr -d ' \n')
|
|
printf '%s' "${NEW_PW}" > "${PASS_FILE}"
|
|
chmod 600 "${PASS_FILE}" || true
|
|
log "generated random TelnetPassword (persisted to ${PASS_FILE})"
|
|
fi
|
|
# Replace the empty-password line in-place. Using | as sed delimiter so
|
|
# the replacement can contain / without escaping.
|
|
sed -i "s|<property name=\"TelnetPassword\"[[:space:]]*value=\"\"[[:space:]]*/>|<property name=\"TelnetPassword\" value=\"${NEW_PW}\"/>|" "${CONFIG}"
|
|
fi
|
|
|
|
# --- patch port properties from env on every boot ---
|
|
# Multi-instance safety: the panel allocator picks unique host ports per
|
|
# instance, the agent injects them as env vars, and we patch them into
|
|
# serverconfig.xml here so 7DTD actually binds them. Without this, two
|
|
# 7DTD instances on the same agent would both default to 26900/8081/8080
|
|
# and only one would launch successfully.
|
|
#
|
|
# 7DTD computes the LiteNetLib alt + p2p backup ports as ServerPort+1 and
|
|
# ServerPort+2 internally — only ServerPort needs to be patched. We DO
|
|
# allocate the +1/+2 slots in module.yaml so the panel knows they're taken.
|
|
log "patching ports: ServerPort=${GAME_PORT}, TelnetPort=${TELNET_PORT}, WebDashboardPort/ControlPanelPort=${WEBDASH_PORT}"
|
|
# NOTE: 7DTD 1.0 renamed the web port property "ControlPanelPort" → "WebDashboardPort".
|
|
# Both are patched so this works across versions; the legacy line is a harmless
|
|
# no-op on 1.0 configs (no ControlPanelPort property to match). Without the
|
|
# WebDashboardPort line every instance kept the template default 8080 and, on
|
|
# this host-network agent, all but the first to boot threw the Allocs WebServer
|
|
# "Address already in use" exception.
|
|
sed -i -E \
|
|
-e "s|(<property[[:space:]]+name=\"ServerPort\"[[:space:]]+value=)\"[0-9]+\"|\1\"${GAME_PORT}\"|" \
|
|
-e "s|(<property[[:space:]]+name=\"TelnetPort\"[[:space:]]+value=)\"[0-9]+\"|\1\"${TELNET_PORT}\"|" \
|
|
-e "s|(<property[[:space:]]+name=\"ControlPanelPort\"[[:space:]]+value=)\"[0-9]+\"|\1\"${WEBDASH_PORT}\"|" \
|
|
-e "s|(<property[[:space:]]+name=\"WebDashboardPort\"[[:space:]]+value=)\"[0-9]+\"|\1\"${WEBDASH_PORT}\"|" \
|
|
"${CONFIG}"
|
|
|
|
# --- point savegame / userdata into the save volume ---
|
|
# 7DTD writes saves + admin files under $HOME/.local/share/7DaysToDie by
|
|
# default. We redirect that whole tree into /game-saves so steam validate
|
|
# runs against /game can't stomp on world data.
|
|
mkdir -p "${SAVE_DIR}/.local/share/7DaysToDie"
|
|
export HOME=${SAVE_DIR}
|
|
|
|
# Surface the user data tree as /game/User so the file browser shows it
|
|
# alongside Data/, Mods/, steamapps/ — AMP-style flat root layout. The
|
|
# agent's lister uses `ls -lAL` which dereferences symlinks, so this
|
|
# renders as a real directory in the Files tab. Guard: only create if
|
|
# nothing's there OR what's there is already a symlink we can replace —
|
|
# never blow away a real directory an operator might have created.
|
|
if [[ ! -e "${GAME_DIR}/User" ]] || [[ -L "${GAME_DIR}/User" ]]; then
|
|
ln -sfn "${SAVE_DIR}/.local/share/7DaysToDie" "${GAME_DIR}/User"
|
|
else
|
|
log "WARN: ${GAME_DIR}/User exists as a real directory, leaving it alone"
|
|
fi
|
|
|
|
# --- cluster: share the Player save folder across cluster members ---
|
|
# When this instance is in a cluster, CLUSTER_ID is set and /cluster is a
|
|
# shared host bind mount (panel mount-override). We symlink this world's
|
|
# Player/ subfolder to /cluster/Player so every member reads/writes the same
|
|
# player .ttp files (shared progression). World data stays per-server.
|
|
if [[ -n "${CLUSTER_ID:-}" ]]; then
|
|
GW=$(grep -oP 'name="GameWorld"\s+value="\K[^"]*' "${CONFIG}" || true)
|
|
GN=$(grep -oP 'name="GameName"\s+value="\K[^"]*' "${CONFIG}" || true)
|
|
SAVES_ROOT="${SAVE_DIR}/.local/share/7DaysToDie/Saves"
|
|
# WHICH world's Player folder gets shared into /cluster/Player is chosen
|
|
# EXPLICITLY by the operator in the panel's Cluster tab ("Shared player
|
|
# world" picker). 7DTD names the save folder after the WORLD, and for
|
|
# random-gen worlds that name is seed-derived (e.g. "Tefasizi County", not
|
|
# the literal "RWG") and changes whenever a new map is generated — so a
|
|
# fixed GameWorld/GameName path silently orphans the symlink. The picker
|
|
# writes CLUSTER_PLAYER_SAVE = "<World>/<GameName>" and we use it verbatim.
|
|
# Until the operator picks one we fall back to the literal
|
|
# Saves/<GameWorld>/<GameName> (correct when GameWorld is already a concrete
|
|
# world name; an RWG server will just need a one-time pick after first boot).
|
|
if [[ -n "${CLUSTER_PLAYER_SAVE:-}" ]]; then
|
|
SAVE_BASE="${SAVES_ROOT}/${CLUSTER_PLAYER_SAVE}"
|
|
log "cluster ${CLUSTER_ID}: shared player world selected by operator -> ${CLUSTER_PLAYER_SAVE}"
|
|
elif [[ -n "${GW}" && -n "${GN}" ]]; then
|
|
SAVE_BASE="${SAVES_ROOT}/${GW}/${GN}"
|
|
log "cluster ${CLUSTER_ID}: shared player world = ${GW}/${GN} (literal; pick one in the Cluster tab if this server uses RWG)"
|
|
else
|
|
SAVE_BASE=""
|
|
log "WARN: CLUSTER_ID set but no CLUSTER_PLAYER_SAVE and GameWorld/GameName unparseable — skipping Player symlink"
|
|
fi
|
|
if [[ -n "${SAVE_BASE}" ]]; then
|
|
PLAYER_DIR="${SAVE_BASE}/Player"
|
|
mkdir -p "${SAVE_BASE}" /cluster/Player
|
|
# Record the resolved active-world save dir so the agent's auto-provision
|
|
# watcher can locate this world's decoration.7dt (to detect a fresh
|
|
# native-banded world that needs aligning to the cluster canonical).
|
|
printf '%s' "${SAVE_BASE}" > "${SAVE_DIR}/.panel-save-base" 2>/dev/null || true
|
|
# One-time migration: when this server has a real (non-symlink) Player
|
|
# dir from standalone play, seed the shared cluster dir from it so this
|
|
# server's existing progression becomes the cluster source of truth.
|
|
# Hardened for irreplaceable saves (e.g. Season 10):
|
|
# * only seed when the shared dir is still EMPTY (first member wins —
|
|
# never copy into an already-seeded cluster),
|
|
# * snapshot the local Player dir before touching it,
|
|
# * only delete the local copy once the seeding copy has SUCCEEDED, so
|
|
# a partial copy can never destroy the server's only Player files.
|
|
do_symlink=1
|
|
if [[ -d "${PLAYER_DIR}" && ! -L "${PLAYER_DIR}" ]]; then
|
|
if [[ -z "$(ls -A /cluster/Player 2>/dev/null)" ]]; then
|
|
snap="${SAVE_BASE}/Player.preseed-${CLUSTER_ID}"
|
|
log "cluster ${CLUSTER_ID}: seeding empty shared Player dir from local saves (snapshot -> ${snap})"
|
|
cp -a "${PLAYER_DIR}" "${snap}" 2>/dev/null || log "WARN: pre-seed snapshot failed (continuing)"
|
|
if cp -a "${PLAYER_DIR}/." /cluster/Player/ 2>/dev/null; then
|
|
rm -rf "${PLAYER_DIR}"
|
|
else
|
|
log "ERROR: failed to seed /cluster/Player from ${PLAYER_DIR}; KEEPING local Player, NOT symlinking (no data lost)"
|
|
do_symlink=0
|
|
fi
|
|
else
|
|
archive="${SAVE_BASE}/Player.local-pre-cluster"
|
|
log "cluster ${CLUSTER_ID}: shared Player dir already populated; archiving local Player -> ${archive}, adopting shared progression"
|
|
rm -rf "${archive}" 2>/dev/null || true
|
|
mv "${PLAYER_DIR}" "${archive}" 2>/dev/null || rm -rf "${PLAYER_DIR}"
|
|
fi
|
|
fi
|
|
if [[ "${do_symlink}" == "1" ]]; then
|
|
ln -sfn /cluster/Player "${PLAYER_DIR}"
|
|
chown -R 1000:1000 /cluster/Player 2>/dev/null || true
|
|
log "cluster ${CLUSTER_ID}: ${PLAYER_DIR} -> /cluster/Player"
|
|
fi
|
|
|
|
# --- auto-provision: align a NEW world's native decorations to canonical ---
|
|
# A freshly-generated cluster world bakes decoration.7dt / multiblocks.7dt by
|
|
# NUMERIC block id using the engine's NATIVE id table at generation. The stamp
|
|
# guard below then puts the CANONICAL .nim over the world's .nim, so those
|
|
# native-baked deco ids resolve to a null Block -> DecoManager NRE -> the world
|
|
# loads but never reaches GameStartDone ("online but unjoinable"). So BEFORE
|
|
# stamping, if this world's decorations are still in the NATIVE id band, remap
|
|
# them name-by-name to canonical using THIS WORLD'S OWN native blockmappings.nim
|
|
# (which the engine wrote at generation and is still on disk this boot, before
|
|
# the stamp). Tree-deco band: native < 24000 / canonical >= 24000 (verified).
|
|
# STRICT no-op for already-canon worlds (season10/creative/insane/pvp -> deco in
|
|
# canon band, never enters the branch), non-cluster servers (outside CLUSTER_ID),
|
|
# and steady state (post-remap deco is canon-band). multiblocks.7dt uses the same
|
|
# full native table (NOT a deco subset). See nim-freeze/REMAP_RUNBOOK.md.
|
|
DECO_F="${SAVE_BASE}/decoration.7dt"
|
|
CANON_B="/cluster/canonical/blockmappings.nim"
|
|
NAT_NIM="${SAVE_BASE}/blockmappings.nim"
|
|
REMAP_TOOL="/opt/panel-nimtools/remap_7dt.py"
|
|
MARK="${SAVE_BASE}/.canon-provisioned"
|
|
if [[ -f "${DECO_F}" && -f "${CANON_B}" && -f "${NAT_NIM}" && -f "${REMAP_TOOL}" ]] && command -v python3 >/dev/null 2>&1; then
|
|
# first decoration block id = u16 @ file offset 17 (hdr 5 + record id-offset 12); mask to 15 bits.
|
|
first_raw="$(od -An -tu2 -j17 -N2 "${DECO_F}" 2>/dev/null | tr -d ' ' || true)"
|
|
[[ "${first_raw}" =~ ^[0-9]+$ ]] || first_raw=0
|
|
first_deco=$(( first_raw & 0x7FFF ))
|
|
if [[ "${first_deco}" -gt 0 && "${first_deco}" -lt 24000 ]]; then
|
|
# Native-band deco present. Safety: confirm the on-disk .nim is the world's
|
|
# NATIVE table (count != canonical count), so we remap against the right map
|
|
# rather than a previously-stamped canonical (which would silently morph).
|
|
nat_cnt="$(od -An -tu4 -j4 -N4 "${NAT_NIM}" 2>/dev/null | tr -d ' ' || true)"; [[ "${nat_cnt}" =~ ^[0-9]+$ ]] || nat_cnt=0
|
|
canon_cnt="$(od -An -tu4 -j4 -N4 "${CANON_B}" 2>/dev/null | tr -d ' ' || true)"; [[ "${canon_cnt}" =~ ^[0-9]+$ ]] || canon_cnt=0
|
|
if [[ "${nat_cnt}" -gt 0 && "${nat_cnt}" != "${canon_cnt}" ]]; then
|
|
log "cluster ${CLUSTER_ID}: new world '${SAVE_BASE##*/Saves/}' has native-band decorations (first id ${first_deco}); aligning decorations to cluster (remap native->canonical)…"
|
|
remap_ok=1
|
|
for f in "${DECO_F}" "${SAVE_BASE}/multiblocks.7dt"; do
|
|
[[ -f "${f}" ]] || continue
|
|
if python3 "${REMAP_TOOL}" apply "${NAT_NIM}" "${CANON_B}" "${f}" "${f}.canon-tmp" > "/tmp/decoremap.$$" 2>&1; then
|
|
cp -f "${f}" "${f}.native-bak"
|
|
mv -f "${f}.canon-tmp" "${f}"
|
|
chown 1000:1000 "${f}" 2>/dev/null || true
|
|
log "cluster ${CLUSTER_ID}: remapped $(basename "${f}") native->canonical ($(grep -oE 'records_rewritten=[0-9]+' "/tmp/decoremap.$$" || true))"
|
|
else
|
|
log "ERROR: cluster ${CLUSTER_ID}: remap of $(basename "${f}") FAILED — refusing to start (would be unjoinable). remap output:"
|
|
while IFS= read -r _l; do log " ${_l}"; done < "/tmp/decoremap.$$"
|
|
rm -f "${f}.canon-tmp" "/tmp/decoremap.$$"
|
|
remap_ok=0
|
|
break
|
|
fi
|
|
done
|
|
rm -f "/tmp/decoremap.$$" 2>/dev/null || true
|
|
if [[ "${remap_ok}" == "1" ]]; then
|
|
sha256sum "${CANON_B}" 2>/dev/null | cut -d' ' -f1 > "${MARK}" || true
|
|
chown 1000:1000 "${MARK}" 2>/dev/null || true
|
|
log "cluster ${CLUSTER_ID}: decorations aligned to cluster canonical — world is now canon-compatible"
|
|
else
|
|
exit 1
|
|
fi
|
|
else
|
|
log "WARN: cluster ${CLUSTER_ID}: native-band decorations but world .nim count=${nat_cnt} == canonical (${canon_cnt}); cannot identify the native table to remap against — leaving as-is (may NRE; see nim-freeze/REMAP_RUNBOOK.md to fix manually)"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# --- cluster canonical .nim freeze guard ---
|
|
# Stamp the cluster's canonical block/item id maps over THIS world's .nim
|
|
# on every boot, so every member resolves the shared player inventory
|
|
# through the SAME id table. This eliminates the per-world id divergence
|
|
# that otherwise morphs inventory items (glass block -> car hood) and wipes
|
|
# characters to level 1 on transfer: 7DTD stores inventory by NUMERIC id,
|
|
# each world bakes its own name<->id table (itemmappings.nim/blockmappings.nim),
|
|
# and a shared .ttp decoded through a different table resolves every id to a
|
|
# different name. The canonical is the season10-anchored, registry-COMPLETE
|
|
# table (every block incl. the full base:shape cross-product, every item +
|
|
# every item_modifier), so the engine never has to append a fresh id -> the
|
|
# table stays frozen. It lives ONCE in the shared cluster dir
|
|
# (/cluster/canonical) and is applied only when the world save dir already
|
|
# exists (a freshly-generated world is frozen on its FIRST reboot).
|
|
# Idempotent + self-healing: re-stamped every boot, so a recreate/regen or
|
|
# any stray append can never silently re-diverge the cluster.
|
|
if [[ -f /cluster/canonical/blockmappings.nim && -f /cluster/canonical/itemmappings.nim && -d "${SAVE_BASE}" ]]; then
|
|
cp -f /cluster/canonical/itemmappings.nim "${SAVE_BASE}/itemmappings.nim" || log "WARN: canonical item-id-map stamp failed"
|
|
cp -f /cluster/canonical/blockmappings.nim "${SAVE_BASE}/blockmappings.nim" || log "WARN: canonical block-id-map stamp failed"
|
|
log "cluster ${CLUSTER_ID}: stamped canonical id maps over ${SAVE_BASE} (frozen, divergence-proof)"
|
|
fi
|
|
fi
|
|
else
|
|
# Not clustered: if a stale symlink to /cluster lingers from a prior
|
|
# membership, drop it so 7DTD recreates a local Player dir.
|
|
GW=$(grep -oP 'name="GameWorld"\s+value="\K[^"]*' "${CONFIG}" || true)
|
|
GN=$(grep -oP 'name="GameName"\s+value="\K[^"]*' "${CONFIG}" || true)
|
|
if [[ -n "${GW}" && -n "${GN}" ]]; then
|
|
PLAYER_DIR="${SAVE_DIR}/.local/share/7DaysToDie/Saves/${GW}/${GN}/Player"
|
|
if [[ -L "${PLAYER_DIR}" ]]; then rm -f "${PLAYER_DIR}"; fi
|
|
fi
|
|
fi
|
|
|
|
# --- sync panel-owned mods into /game/Mods/ ---
|
|
# /opt/panel-mods/ is baked into the image. We rsync (well, cp -a) each
|
|
# subdir into /game/Mods/ on every boot so:
|
|
# - a fresh SteamCMD install picks up our mods automatically
|
|
# - operator-deleted mods are restored
|
|
# - image rebuilds (with newer mod DLLs) propagate without manual work
|
|
# We DO NOT remove other operator-installed mods — only overwrite
|
|
# panel-prefixed ones (named "Panel*").
|
|
if [[ -d /opt/panel-mods ]]; then
|
|
mkdir -p "${GAME_DIR}/Mods"
|
|
for src in /opt/panel-mods/*/; do
|
|
name=$(basename "${src}")
|
|
dest="${GAME_DIR}/Mods/${name}"
|
|
log "panel mod sync: ${name} → ${dest}"
|
|
rm -rf "${dest}"
|
|
cp -a "${src}" "${dest}"
|
|
done
|
|
fi
|
|
|
|
# --- go ---
|
|
cd "${GAME_DIR}"
|
|
log "starting 7 Days to Die dedicated server"
|
|
log " game dir: ${GAME_DIR}"
|
|
log " save dir: ${SAVE_DIR}"
|
|
log " config: ${CONFIG}"
|
|
log " game port: ${GAME_PORT}/udp (+1, +2 implicit)"
|
|
log " telnet port: ${TELNET_PORT}/tcp"
|
|
log " control port: ${WEBDASH_PORT}/tcp"
|
|
|
|
# IMPORTANT: we deliberately do NOT use ./startserver.sh — the shipped
|
|
# script hardcodes `-logfile <file>`, routing all server output to a
|
|
# filesystem log that the panel can't tail without extra bind-mount
|
|
# gymnastics. Invoking the binary directly with `-logfile -` streams
|
|
# every line to stdout, which docker logs picks up, which the panel
|
|
# Console tab can surface in real time.
|
|
#
|
|
# LD_LIBRARY_PATH is what startserver.sh set — the game loads local .so
|
|
# files, not system ones, so this is mandatory.
|
|
export LD_LIBRARY_PATH=.
|
|
|
|
exec stdbuf -oL -eL ./7DaysToDieServer.x86_64 \
|
|
-configfile=serverconfig.xml \
|
|
-logfile - \
|
|
-quit -batchmode -nographics -dedicated
|