Files
panel/modules/7dtd/entrypoint.sh
T
dbledeez 8a94ffd58f panel public release
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 01:13:02 -07:00

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