#!/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 '' "${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|||" "${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|(/ (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 `, 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