panel v0.9.2 — open-source game server manager
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
# panel-native Valheim runtime image.
|
||||
FROM debian:12-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
TZ=Etc/UTC \
|
||||
LANG=en_US.UTF-8 \
|
||||
LC_ALL=en_US.UTF-8
|
||||
|
||||
RUN dpkg --add-architecture i386 \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
locales \
|
||||
tini \
|
||||
lib32gcc-s1 \
|
||||
lib32stdc++6 \
|
||||
libc6:i386 \
|
||||
libpulse0 \
|
||||
libatomic1 \
|
||||
&& sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \
|
||||
&& locale-gen \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN groupadd -g 1000 panel \
|
||||
&& useradd -u 1000 -g 1000 -m -s /bin/bash panel \
|
||||
&& mkdir -p /game /game-saves \
|
||||
&& chown -R panel:panel /game /game-saves
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 2456/udp 2457/udp 2458/udp
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/bin/bash
|
||||
# panel-native Valheim entrypoint.
|
||||
set -euo pipefail
|
||||
log() { printf '[panel-valheim] %s\n' "$*"; }
|
||||
|
||||
# Root-stage: chown (SteamCMD sidecar wrote as root) then drop to panel.
|
||||
if [[ "$(id -u)" = "0" ]]; then
|
||||
chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true
|
||||
exec setpriv --reuid=panel --regid=panel --clear-groups \
|
||||
--inh-caps=-all --bounding-set=-all \
|
||||
env HOME=/game-saves PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
|
||||
SERVER_NAME="${SERVER_NAME:-panel Valheim}" \
|
||||
SERVER_WORLD="${SERVER_WORLD:-Dedicated}" \
|
||||
SERVER_PASSWORD="${SERVER_PASSWORD:-secret12}" \
|
||||
SERVER_PUBLIC="${SERVER_PUBLIC:-1}" \
|
||||
/entrypoint.sh "$@"
|
||||
fi
|
||||
|
||||
GAME_DIR=/game
|
||||
SAVE_DIR=/game-saves
|
||||
EXE="${GAME_DIR}/valheim_server.x86_64"
|
||||
CFG="${SAVE_DIR}/server.cfg"
|
||||
|
||||
if [[ ! -x "${EXE}" ]]; then
|
||||
log "ERROR: ${EXE} not found. Run 'Update' in the panel to install Valheim via SteamCMD."
|
||||
exit 78
|
||||
fi
|
||||
|
||||
# --- settings source of truth: PANEL ENV (config_values) ---
|
||||
# The panel is the single editing surface (gamehost Settings page writes
|
||||
# config_values → these env vars on recreate). The env values ALWAYS win.
|
||||
#
|
||||
# We REGENERATE /game-saves/server.cfg from the live env on every boot, purely
|
||||
# as a human-readable reflection of the active settings — it is NOT sourced
|
||||
# back in. The previous version seeded it once and then SOURCED it on every
|
||||
# boot, so a stale server.cfg silently overrode every panel edit (a customer's
|
||||
# renamed server / new password never took effect, and the Settings page showed
|
||||
# values that disagreed with the running server). Fixed 2026-06-02.
|
||||
cat > "${CFG}" <<EOF
|
||||
# AUTO-GENERATED from the panel on every boot — DO NOT EDIT (changes are
|
||||
# overwritten). Edit settings from the gamehost Settings page instead.
|
||||
SERVER_NAME="${SERVER_NAME}"
|
||||
SERVER_WORLD="${SERVER_WORLD}"
|
||||
SERVER_PASSWORD="${SERVER_PASSWORD}"
|
||||
SERVER_PUBLIC=${SERVER_PUBLIC}
|
||||
SERVER_SAVEINTERVAL=${SERVER_SAVEINTERVAL:-1800}
|
||||
SERVER_PRESET="${SERVER_PRESET:-}"
|
||||
SERVER_MODIFIERS="${SERVER_MODIFIERS:-}"
|
||||
EOF
|
||||
|
||||
# Valheim writes worlds + adminlist.txt under $HOME/.config/unity3d/IronGate/Valheim.
|
||||
# HOME is already /game-saves via setpriv; pre-create so perms are right.
|
||||
mkdir -p "${SAVE_DIR}/.config/unity3d/IronGate/Valheim"
|
||||
|
||||
export LD_LIBRARY_PATH="${GAME_DIR}/linux64:${LD_LIBRARY_PATH:-}"
|
||||
export SteamAppId=892970 # as per Valheim docs (892970, not the dedicated app id)
|
||||
|
||||
# Port: bind the panel-allocated host port (host networking → the game listens
|
||||
# directly on the LAN at this port). The panel injects PORT_GAME via the
|
||||
# `env:` mapping on the `game` ports entry in module.yaml; fall back to 2456
|
||||
# only if it's somehow unset (e.g. a hand-run container). Valheim opens
|
||||
# ${GAME_PORT} for gameplay and ${GAME_PORT}+1 for the Steam server query, so
|
||||
# the public chain must forward BOTH (panel allocates them contiguously).
|
||||
GAME_PORT="${PORT_GAME:-2456}"
|
||||
|
||||
# --- optional flags: only appended when the corresponding env var is set, so
|
||||
# an empty value never injects a broken flag. These are surfaced as curated
|
||||
# fields in the gamehost Settings page.
|
||||
EXTRA_ARGS=()
|
||||
|
||||
# Crossplay is intentionally DISABLED and not honoured. Valheim's -crossplay
|
||||
# switches the server to PlayFab relay networking and opens NO local inbound
|
||||
# listening port ("no inbound listening ports in Crossplay" — Iron Gate), so
|
||||
# players can no longer join by IP:port. That breaks gamehost's entire connect
|
||||
# model (we hand customers an IP:port). A customer enabling it bricked their
|
||||
# direct-IP join on 2026-06-02. We never pass -crossplay regardless of any
|
||||
# SERVER_CROSSPLAY value that might linger in an old config.
|
||||
if [[ "${SERVER_CROSSPLAY:-0}" != "0" ]]; then
|
||||
log "NOTE: SERVER_CROSSPLAY is set but crossplay is disabled on this host (it would break direct IP:port joining). Ignoring."
|
||||
fi
|
||||
|
||||
# Save interval in seconds (Valheim default 1800). Only pass if numeric > 0.
|
||||
if [[ "${SERVER_SAVEINTERVAL:-}" =~ ^[0-9]+$ ]] && [[ "${SERVER_SAVEINTERVAL}" -gt 0 ]]; then
|
||||
EXTRA_ARGS+=(-saveinterval "${SERVER_SAVEINTERVAL}")
|
||||
fi
|
||||
|
||||
# World preset: one of the vanilla presets (normal/casual/easy/hard/hardcore/
|
||||
# immersive/hammer). Empty = leave at the world's existing settings.
|
||||
if [[ -n "${SERVER_PRESET:-}" ]]; then
|
||||
EXTRA_ARGS+=(-preset "${SERVER_PRESET}")
|
||||
fi
|
||||
|
||||
# Per-key world modifiers: SERVER_MODIFIERS holds space-separated "key value"
|
||||
# pairs, e.g. "combat hard deathpenalty casual resources more". Each pair
|
||||
# becomes a "-modifier key value". Operator-facing fields write a clean string.
|
||||
if [[ -n "${SERVER_MODIFIERS:-}" ]]; then
|
||||
# shellcheck disable=SC2206
|
||||
_mods=(${SERVER_MODIFIERS})
|
||||
i=0
|
||||
while [[ $i -lt ${#_mods[@]} ]]; do
|
||||
key="${_mods[$i]}"; val="${_mods[$((i+1))]:-}"
|
||||
if [[ -n "${key}" && -n "${val}" ]]; then
|
||||
EXTRA_ARGS+=(-modifier "${key}" "${val}")
|
||||
fi
|
||||
i=$((i+2))
|
||||
done
|
||||
fi
|
||||
|
||||
# --- Mods: BepInEx (Doorstop) loader ---------------------------------------
|
||||
# Valheim mods load through BepInEx, injected at launch by Unity Doorstop. The
|
||||
# panel's mod manager installs BepInEx + plugins into /game/BepInEx (the
|
||||
# Thunderstore/ValheimPlus zips extract their BepInEx/ tree + doorstop_libs +
|
||||
# libdoorstop into /game). We enable injection ONLY when:
|
||||
# - mods are turned on (VALHEIM_MODS_ENABLED=1, set by the panel), AND
|
||||
# - the loader is actually present on disk (BepInEx/core/BepInEx.Preloader.dll)
|
||||
# Otherwise we launch vanilla (no perf cost, no risk of a half-install bricking
|
||||
# the server). cwd is already /game below so the relative paths resolve.
|
||||
BEPINEX_PRELOADER="${GAME_DIR}/BepInEx/core/BepInEx.Preloader.dll"
|
||||
MODS_ACTIVE=0
|
||||
if [[ "${VALHEIM_MODS_ENABLED:-0}" != "0" && -f "${BEPINEX_PRELOADER}" ]]; then
|
||||
MODS_ACTIVE=1
|
||||
fi
|
||||
|
||||
# ValheimPlus config (valheim_plus.cfg) is NOT generated here. gamehost writes
|
||||
# the full cfg directly into /game/BepInEx/config/valheim_plus.cfg via the file
|
||||
# API whenever the customer saves V+ settings (it holds all 345 settings in
|
||||
# config_values as VP_* and renders the cfg from them). Keeping cfg generation
|
||||
# out of the container env avoids declaring 345 env vars per instance.
|
||||
|
||||
cd "${GAME_DIR}"
|
||||
log "starting Valheim dedicated server"
|
||||
log " name: ${SERVER_NAME}"
|
||||
log " world: ${SERVER_WORLD}"
|
||||
log " public: ${SERVER_PUBLIC}"
|
||||
log " port: ${GAME_PORT} (+ ${GAME_PORT}+1 for query)"
|
||||
log " extra: ${EXTRA_ARGS[*]:-(none)}"
|
||||
|
||||
if [[ "${MODS_ACTIVE}" = "1" ]]; then
|
||||
log " mods: BepInEx ENABLED${VALHEIM_PLUS_ENABLED:+ (ValheimPlus)}"
|
||||
# Doorstop env (from ValheimPlus/BepInEx start_server_bepinex.sh). Paths are
|
||||
# relative to cwd (=/game).
|
||||
export DOORSTOP_ENABLED=1
|
||||
export DOORSTOP_ENABLE=TRUE
|
||||
export DOORSTOP_TARGET_ASSEMBLY="./BepInEx/core/BepInEx.Preloader.dll"
|
||||
export DOORSTOP_INVOKE_DLL_PATH="./BepInEx/core/BepInEx.Preloader.dll"
|
||||
export LD_LIBRARY_PATH="./doorstop_libs:${GAME_DIR}/linux64:${LD_LIBRARY_PATH:-}"
|
||||
export LD_PRELOAD="libdoorstop_x64.so:${LD_PRELOAD:-}"
|
||||
else
|
||||
log " mods: none (vanilla)"
|
||||
fi
|
||||
|
||||
# --- Passwordless via ValheimPlus -----------------------------------------
|
||||
# disableServerPassword=true in valheim_plus.cfg is NOT enough on its own:
|
||||
# Valheim still enforces the password if the launch command passes -password,
|
||||
# and it refuses a passwordless server unless it's also -public 0 (unlisted).
|
||||
# So when V+ is active AND its cfg has disableServerPassword=true, we OMIT
|
||||
# -password and force -public 0. (Confirmed: Iron Gate / ServerFlex / multiple
|
||||
# V+ docker projects all hit exactly this.)
|
||||
PW_ARGS=(-password "${SERVER_PASSWORD}")
|
||||
PUBLIC_ARG="${SERVER_PUBLIC}"
|
||||
VP_CFG="${GAME_DIR}/BepInEx/config/valheim_plus.cfg"
|
||||
if [[ "${MODS_ACTIVE}" = "1" && "${VALHEIM_PLUS_ENABLED:-0}" != "0" && -f "${VP_CFG}" ]]; then
|
||||
# Match `disableServerPassword=true` only inside the [Server] section.
|
||||
if awk '
|
||||
/^\[Server\]/ {inserver=1; next}
|
||||
/^\[/ {inserver=0}
|
||||
inserver && /^[[:space:]]*disableServerPassword[[:space:]]*=[[:space:]]*true[[:space:]]*$/ {found=1}
|
||||
END {exit found?0:1}
|
||||
' "${VP_CFG}"; then
|
||||
log " password: DISABLED via ValheimPlus (omitting -password, forcing -public 0)"
|
||||
PW_ARGS=()
|
||||
PUBLIC_ARG="0"
|
||||
fi
|
||||
fi
|
||||
|
||||
exec stdbuf -oL -eL "${EXE}" \
|
||||
-name "${SERVER_NAME}" \
|
||||
-port "${GAME_PORT}" \
|
||||
-world "${SERVER_WORLD}" \
|
||||
"${PW_ARGS[@]}" \
|
||||
-public "${PUBLIC_ARG}" \
|
||||
-savedir "${SAVE_DIR}" \
|
||||
"${EXTRA_ARGS[@]}" \
|
||||
-nographics -batchmode
|
||||
@@ -0,0 +1,117 @@
|
||||
# Valheim — panel-native dedicated server module.
|
||||
#
|
||||
# Native Linux binary from SteamCMD app 896660. No RCON natively — the
|
||||
# server reads admin commands from stdin and prints to stdout, which is
|
||||
# a "STDIO adapter" thing we haven't shipped yet. So v1 runs without an
|
||||
# RCON/Players tab, same story as Empyrion.
|
||||
#
|
||||
# Reference: https://github.com/CubeCoders/AMPTemplates (Valheim.kvp)
|
||||
|
||||
id: valheim
|
||||
name: "Valheim"
|
||||
version: 0.1.0
|
||||
authors:
|
||||
- panel contributors
|
||||
|
||||
supported_modes:
|
||||
- docker
|
||||
|
||||
runtime:
|
||||
docker:
|
||||
image: panel-valheim:latest
|
||||
# Host networking -- container shares the host net namespace, so
|
||||
# any port the game/mod binds is directly on the LAN. AMP-like
|
||||
# model. Multi-instance safety comes from env-driven per-instance
|
||||
# ports (panel allocator + env: mapping on each ports entry).
|
||||
network_mode: host
|
||||
browseable_root: /game-saves
|
||||
browseable_roots:
|
||||
- name: "Saves & Configs"
|
||||
path: /game-saves
|
||||
hint: "Worlds + adminlist.txt + banlist.txt (via $HOME/.config/unity3d)"
|
||||
- name: "Game Files"
|
||||
path: /game
|
||||
hint: "Binaries, plugins — drop BepInEx/Jotunn here"
|
||||
env:
|
||||
SERVER_NAME: "panel Valheim"
|
||||
SERVER_WORLD: "Dedicated"
|
||||
SERVER_PASSWORD: "secret12" # Valheim requires ≥5 chars + not part of name/world
|
||||
SERVER_PUBLIC: "1" # 1 = listed, 0 = unlisted
|
||||
# Optional curated knobs (surfaced as Settings fields in gamehost). Must
|
||||
# be pre-declared here so the agent's config_values override is honoured
|
||||
# by ResolveDocker (it only passes through keys already in this block).
|
||||
SERVER_CROSSPLAY: "0" # 1 = enable PlayFab crossplay
|
||||
SERVER_SAVEINTERVAL: "1800" # world autosave interval, seconds
|
||||
SERVER_PRESET: "" # normal|casual|easy|hard|hardcore|immersive|hammer
|
||||
SERVER_MODIFIERS: "" # "combat hard deathpenalty casual ..." pairs
|
||||
VALHEIM_MODS_ENABLED: "0" # 1 = inject BepInEx/Doorstop at launch (mods installed)
|
||||
VALHEIM_PLUS_ENABLED: "0" # 1 = ValheimPlus is the active mod (drives the V+ settings UI)
|
||||
# Port env vars MUST be pre-declared here (with the manifest default) so
|
||||
# the agent's per-port `env:` injection is allowed to override them at
|
||||
# create time. ResolveDocker only honours config_values for keys that
|
||||
# already exist in this static env block — otherwise the injected
|
||||
# PORT_GAME is silently dropped and the entrypoint falls back to 2456.
|
||||
PORT_GAME: "2456"
|
||||
PORT_GAMEA: "2457"
|
||||
PORT_QUERY: "2458"
|
||||
volumes:
|
||||
- { type: volume, name: "panel-$INSTANCE_ID-saves", container: "/game-saves" }
|
||||
- { type: volume, name: "panel-$INSTANCE_ID-game", container: "/game" }
|
||||
- { target: "$DATA_PATH/logs", container: "/game-saves/logs" }
|
||||
|
||||
# The panel allocator assigns a per-instance host port for each entry and,
|
||||
# thanks to the `env:` mapping, injects it into the container so the entrypoint
|
||||
# binds the ALLOCATED port instead of the hardcoded default. Without this the
|
||||
# game binds 2456/2457 regardless of allocation and every public forward (which
|
||||
# targets the allocated port) misses — server unreachable / not in the browser.
|
||||
# Valheim actually opens <port> and <port>+1 for play and <port>+1 for the
|
||||
# Steam query/browser; we pass PORT_GAME and let the binary take port+1. The
|
||||
# allocator hands out contiguous ports so gameA/query line up.
|
||||
ports:
|
||||
- { name: game, proto: udp, default: 2456, required: true, env: PORT_GAME }
|
||||
- { name: gameA, proto: udp, default: 2457, required: true, env: PORT_GAMEA }
|
||||
- { name: query, proto: udp, default: 2458, required: true, env: PORT_QUERY }
|
||||
|
||||
resources:
|
||||
min_ram_mb: 2048
|
||||
recommended_ram_mb: 4096
|
||||
|
||||
rcon:
|
||||
# Valheim has no network RCON; admin commands go straight to the server's
|
||||
# stdin console. The stdio adapter auto-enables OpenStdin on the container.
|
||||
adapter: stdio
|
||||
commands:
|
||||
save: "save"
|
||||
broadcast: "broadcast \"{msg}\""
|
||||
kick: "kick {player}"
|
||||
ban: "ban {player}"
|
||||
shutdown: "shutdown"
|
||||
# Log-derived player events. Regexes converted from CubeCoders'
|
||||
# AMPTemplates (valheim.kvp) — .NET (?<n>) syntax -> Go (?P<n>).
|
||||
# Sourced-from-template; not yet exercised against a live client.
|
||||
events:
|
||||
join:
|
||||
pattern: 'Got character ZDOID from (?P<name>.+?) : (?P<id>-?\d+):\d+'
|
||||
kind: join
|
||||
leave:
|
||||
pattern: 'Destroying abandoned non persistent zdo -?\d+:\d+ owner (?P<id>-?\d+)$'
|
||||
kind: leave
|
||||
|
||||
|
||||
update_providers:
|
||||
- id: stable
|
||||
kind: steamcmd
|
||||
app_id: "896660"
|
||||
install_path: /game
|
||||
|
||||
# --- Manifest-driven UI metadata (WI-07) --------------------------
|
||||
# Consumed by the CONTROLLER/dashboard only (the controller loads its
|
||||
# own ./modules copy at startup); the agent ignores these at runtime.
|
||||
# ready_pattern is a JavaScript regex (slash-delimited when it needs
|
||||
# flags) moved verbatim from the dashboard's READY_PATTERNS map.
|
||||
ready_pattern: '/Game server connected|Dedicated server started/i'
|
||||
appearance:
|
||||
emoji: "⚔️"
|
||||
grad: "linear-gradient(135deg,#3b5998,#8da5d4)"
|
||||
art: "/game-art/valheim.jpg"
|
||||
steam: "892970"
|
||||
Reference in New Issue
Block a user