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,63 @@
|
||||
# panel-native VEIN runtime image.
|
||||
#
|
||||
# UE4-class dedicated server. Per the AMP template the Linux server needs
|
||||
# libasound2, libatomic1 and libpulse0 in addition to the usual 32-bit
|
||||
# SteamCMD runtime libs. On debian:12 the ALSA package is `libasound2`
|
||||
# (the `libasound2t64` rename only happened on Ubuntu 24.04 / Debian trixie).
|
||||
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 \
|
||||
curl \
|
||||
lib32gcc-s1 \
|
||||
lib32stdc++6 \
|
||||
libc6:i386 \
|
||||
libatomic1 \
|
||||
libasound2 \
|
||||
libpulse0 \
|
||||
&& 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/*
|
||||
|
||||
# Bake the Steam client SDK into the image. VEIN's depot ships libsteam_api.so
|
||||
# but NOT steamclient.so — the runtime SDK module SteamAPI_Init dlopen's from
|
||||
# ~/.steam/sdk64/. The panel's SteamCMD sidecar only installs the GAME into the
|
||||
# volume and is then removed, so steamclient.so must come from the image. We
|
||||
# fetch steamcmd and run it once (+quit) to self-bootstrap, which downloads
|
||||
# linux32/ + linux64/steamclient.so; we stash both at /opt/steamsdk for the
|
||||
# entrypoint to symlink. (Without this the server runs but never registers with
|
||||
# Steam — "Steam Dedicated Server API failed to initialize" + no heartbeat.)
|
||||
RUN mkdir -p /opt/steamcmd /opt/steamsdk/linux64 /opt/steamsdk/linux32 \
|
||||
&& curl -fsSL "https://steamcdn-a.akamaihd.net/client/installer/steamcmd_linux.tar.gz" | tar -xz -C /opt/steamcmd \
|
||||
&& ( /opt/steamcmd/steamcmd.sh +quit || true ) \
|
||||
&& for f in $(find /opt/steamcmd /root/.steam /root/.local/share/Steam -name steamclient.so 2>/dev/null); do \
|
||||
echo "$f" | grep -qi linux32 && cp -f "$f" /opt/steamsdk/linux32/steamclient.so || cp -f "$f" /opt/steamsdk/linux64/steamclient.so ; \
|
||||
done \
|
||||
&& ls -la /opt/steamsdk/linux64/ /opt/steamsdk/linux32/ \
|
||||
&& test -f /opt/steamsdk/linux64/steamclient.so
|
||||
|
||||
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 /opt/steamsdk
|
||||
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
# Strip any CR (\r) so a Windows-checkout CRLF entrypoint can't make the kernel
|
||||
# look for "/bin/bash\r" → "exec /entrypoint.sh: No such file or directory"
|
||||
# (exit 127 crash-loop). Belt-and-suspenders: the source is LF, but a future
|
||||
# re-checkout on a CRLF-normalizing host shouldn't be able to break the image.
|
||||
RUN sed -i 's/\r$//' /entrypoint.sh && chmod +x /entrypoint.sh
|
||||
|
||||
EXPOSE 7777/udp 27015/udp 7878/udp
|
||||
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/entrypoint.sh"]
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/bin/bash
|
||||
# panel-native VEIN entrypoint.
|
||||
#
|
||||
# Contract:
|
||||
# /game — SteamCMD-installed VEIN Dedicated Server (app 2131400),
|
||||
# populated by the panel's SteamCMD updater. If the server
|
||||
# binary is missing, exit EX_CONFIG (78) with a helpful hint.
|
||||
# /game-saves — Vein/Saved/ tree (savegame + Config/LinuxServer/Game.ini).
|
||||
#
|
||||
# Stage 1 (root): chown volumes (SteamCMD wrote as root), wire the Steam
|
||||
# client SDK (~/.steam/sdk64/steamclient.so) so the Steamworks game-server
|
||||
# API can register on the master server, then drop to the `panel` user.
|
||||
# Stage 2 (panel): stamp config_values env into the CORRECT UE sections of
|
||||
# Game.ini, redirect saves into the volume, then exec the server with the
|
||||
# panel-allocated ports.
|
||||
|
||||
set -euo pipefail
|
||||
log() { printf '[panel-vein] %s\n' "$*"; }
|
||||
|
||||
GAME_DIR=/game
|
||||
SAVE_DIR=/game-saves
|
||||
|
||||
# ----- stage 1: root-only bootstrap -----
|
||||
if [[ "$(id -u)" = "0" ]]; then
|
||||
chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true
|
||||
|
||||
log "dropping privileges to panel (UID 1000)"
|
||||
exec setpriv --reuid=panel --regid=panel --clear-groups \
|
||||
--inh-caps=-all --bounding-set=-all \
|
||||
env \
|
||||
HOME=/home/panel \
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
|
||||
STEAM_APP_ID="${STEAM_APP_ID:-1857950}" \
|
||||
SteamAppId="${STEAM_APP_ID:-1857950}" \
|
||||
GAME_PORT="${GAME_PORT:-7777}" \
|
||||
QUERY_PORT="${QUERY_PORT:-27015}" \
|
||||
RCON_PORT="${RCON_PORT:-7878}" \
|
||||
SERVER_NAME="${SERVER_NAME:-}" \
|
||||
SERVER_DESCRIPTION="${SERVER_DESCRIPTION:-}" \
|
||||
SERVER_PASSWORD="${SERVER_PASSWORD:-}" \
|
||||
MAX_PLAYERS="${MAX_PLAYERS:-}" \
|
||||
SERVER_PUBLIC="${SERVER_PUBLIC:-}" \
|
||||
HEARTBEAT_INTERVAL="${HEARTBEAT_INTERVAL:-}" \
|
||||
ENABLE_VAC="${ENABLE_VAC:-}" \
|
||||
SHOW_SCOREBOARD_BADGES="${SHOW_SCOREBOARD_BADGES:-}" \
|
||||
/entrypoint.sh "$@"
|
||||
fi
|
||||
|
||||
# ----- stage 2: panel user -----
|
||||
EXE="${GAME_DIR}/Vein/Binaries/Linux/VeinServer-Linux-Test"
|
||||
# Fallbacks in case the depot ships a differently-suffixed binary
|
||||
# (Shipping / DebugGame). The smoke test confirms the real name.
|
||||
if [[ ! -x "${EXE}" ]]; then
|
||||
for alt in VeinServer-Linux-Shipping VeinServer-Linux-DebugGame VeinServer-Linux; do
|
||||
if [[ -x "${GAME_DIR}/Vein/Binaries/Linux/${alt}" ]]; then
|
||||
EXE="${GAME_DIR}/Vein/Binaries/Linux/${alt}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ ! -x "${EXE}" ]]; then
|
||||
log "ERROR: VEIN server binary not found under ${GAME_DIR}/Vein/Binaries/Linux/."
|
||||
log " Run 'Update' in the panel to install VEIN via SteamCMD (app 2131400)."
|
||||
exit 78
|
||||
fi
|
||||
|
||||
# --- Steam client SDK: the Steamworks game-server API dlopen's steamclient.so
|
||||
# from ~/.steam/sdk64/ (64-bit) and ~/.steam/sdk32/ (32-bit). VEIN's depot does
|
||||
# NOT ship steamclient.so, and the panel's SteamCMD sidecar (which has it) is
|
||||
# torn down after install — so we bake it into the image at /opt/steamsdk and
|
||||
# symlink it here. Without this: "Steam Dedicated Server API failed to
|
||||
# initialize" + endless "Failed to heartbeat" + the ready-regex never fires.
|
||||
mkdir -p "${HOME}/.steam/sdk64" "${HOME}/.steam/sdk32"
|
||||
for pair in "linux64:sdk64" "linux32:sdk32"; do
|
||||
src="/opt/steamsdk/${pair%%:*}/steamclient.so"
|
||||
dst="${HOME}/.steam/${pair##*:}/steamclient.so"
|
||||
if [[ -f "${src}" ]]; then
|
||||
ln -sf "${src}" "${dst}"
|
||||
fi
|
||||
done
|
||||
# Fallback: if the baked SDK is somehow absent, scan known runtime locations.
|
||||
if [[ ! -e "${HOME}/.steam/sdk64/steamclient.so" ]]; then
|
||||
alt="$(find /opt /game "${HOME}/.steam" "${HOME}/.local/share/Steam" -name steamclient.so 2>/dev/null | grep -i 'linux64\|sdk64' | head -1)"
|
||||
[[ -z "${alt}" ]] && alt="$(find /opt /game "${HOME}/.steam" -name steamclient.so 2>/dev/null | head -1)"
|
||||
[[ -n "${alt}" ]] && ln -sf "${alt}" "${HOME}/.steam/sdk64/steamclient.so"
|
||||
fi
|
||||
if [[ -e "${HOME}/.steam/sdk64/steamclient.so" ]]; then
|
||||
log "steamclient.so wired → $(readlink -f "${HOME}/.steam/sdk64/steamclient.so")"
|
||||
else
|
||||
log "WARNING: steamclient.so not found — Steam server registration will fail (server still LAN-joinable)."
|
||||
fi
|
||||
export LD_LIBRARY_PATH="${HOME}/.steam/sdk64:${GAME_DIR}/linux64:${GAME_DIR}/Vein/Binaries/Linux:${LD_LIBRARY_PATH:-}"
|
||||
|
||||
# --- Game.ini: section-aware env → INI stamping -------------------------
|
||||
# VEIN's Game.ini is a multi-section UE config. Each tunable lives under a
|
||||
# SPECIFIC [/Script/...] header — MaxPlayers under [/Script/Engine.GameSession],
|
||||
# ServerName/Password under [/Script/Vein.VeinGameSession], etc. We stamp
|
||||
# only NON-EMPTY env values, in place, into the right section — preserving
|
||||
# operator hand-edits to other keys/sections via the Files tab.
|
||||
CFG_DIR="${SAVE_DIR}/Vein/Saved/Config/LinuxServer"
|
||||
INI="${CFG_DIR}/Game.ini"
|
||||
mkdir -p "${CFG_DIR}"
|
||||
[[ -f "${INI}" ]] || : > "${INI}"
|
||||
|
||||
# set_ini_kv "[Section]" KEY VALUE
|
||||
# Replace KEY=... if it already exists *within* its section; else insert it
|
||||
# immediately after the section header; create the section at EOF if absent.
|
||||
# Skips on empty VALUE. Section-aware: a same-named key in another section
|
||||
# is left untouched. Idempotent. Pure awk — no extra runtime deps.
|
||||
set_ini_kv() {
|
||||
local section="$1" key="$2" value="$3"
|
||||
[[ -z "${value}" ]] && return 0
|
||||
local tmp="${INI}.tmp.$$"
|
||||
awk -v sec="${section}" -v key="${key}" -v val="${value}" '
|
||||
function isheader(l) { return (l ~ /^[ \t]*\[.*\][ \t]*$/) }
|
||||
function trim(l) { gsub(/^[ \t]+|[ \t]+$/, "", l); return l }
|
||||
BEGIN { cur=""; replaced=0; hdr_line=0 }
|
||||
{
|
||||
lines[NR]=$0
|
||||
t=trim($0)
|
||||
if (isheader(t)) cur=t
|
||||
section_of[NR]=cur
|
||||
if (cur==sec && hdr_line==0 && t==sec) hdr_line=NR
|
||||
# replace in-place when key matches inside the target section
|
||||
if (cur==sec && (t ~ ("^" key "[ \t]*=") )) {
|
||||
lines[NR]=key "=" val
|
||||
replaced=1
|
||||
}
|
||||
}
|
||||
END {
|
||||
if (replaced) {
|
||||
for (i=1;i<=NR;i++) print lines[i]
|
||||
} else if (hdr_line>0) {
|
||||
for (i=1;i<=NR;i++) {
|
||||
print lines[i]
|
||||
if (i==hdr_line) print key "=" val
|
||||
}
|
||||
} else {
|
||||
for (i=1;i<=NR;i++) print lines[i]
|
||||
if (NR>0 && trim(lines[NR])!="") print ""
|
||||
print sec
|
||||
print key "=" val
|
||||
}
|
||||
}
|
||||
' "${INI}" > "${tmp}" && mv "${tmp}" "${INI}"
|
||||
}
|
||||
|
||||
GS='[/Script/Engine.GameSession]'
|
||||
VS='[/Script/Vein.VeinGameSession]'
|
||||
OSS='[OnlineSubsystemSteam]'
|
||||
SS='[/Script/Vein.ServerSettings]'
|
||||
|
||||
set_ini_kv "$GS" "MaxPlayers" "${MAX_PLAYERS}"
|
||||
set_ini_kv "$VS" "ServerName" "${SERVER_NAME}"
|
||||
set_ini_kv "$VS" "ServerDescription" "${SERVER_DESCRIPTION}"
|
||||
set_ini_kv "$VS" "Password" "${SERVER_PASSWORD}"
|
||||
set_ini_kv "$VS" "bPublic" "${SERVER_PUBLIC}"
|
||||
set_ini_kv "$VS" "HeartbeatInterval" "${HEARTBEAT_INTERVAL}"
|
||||
set_ini_kv "$OSS" "bVACEnabled" "${ENABLE_VAC}"
|
||||
set_ini_kv "$SS" "GS_ShowScoreboardBadges" "${SHOW_SCOREBOARD_BADGES}"
|
||||
|
||||
# --- Saves: redirect Vein/Saved into the save volume via symlink so world
|
||||
# data + configs survive container recreate and show in the Files tab.
|
||||
mkdir -p "${SAVE_DIR}/Vein/Saved" "${SAVE_DIR}/logs"
|
||||
if [[ -d "${GAME_DIR}/Vein/Saved" && ! -L "${GAME_DIR}/Vein/Saved" ]]; then
|
||||
if [[ -z "$(ls -A "${SAVE_DIR}/Vein/Saved" 2>/dev/null)" ]]; then
|
||||
cp -a "${GAME_DIR}/Vein/Saved/." "${SAVE_DIR}/Vein/Saved/" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf "${GAME_DIR}/Vein/Saved"
|
||||
fi
|
||||
mkdir -p "${GAME_DIR}/Vein"
|
||||
ln -sfn "${SAVE_DIR}/Vein/Saved" "${GAME_DIR}/Vein/Saved"
|
||||
|
||||
cd "${GAME_DIR}"
|
||||
log "starting VEIN dedicated server"
|
||||
log " name: ${SERVER_NAME:-<unset, Game.ini default>}"
|
||||
log " slots: ${MAX_PLAYERS:-<unset, Game.ini default>}"
|
||||
log " ports: game=${GAME_PORT} query=${QUERY_PORT}"
|
||||
log " binary: ${EXE}"
|
||||
|
||||
# Launch args mirror the AMP template:
|
||||
# Vein -Port=N -QueryPort=N -UseGameSaveDir -stdout -FullStdOutLogOutput
|
||||
# -stdout + -FullStdOutLogOutput are LOAD-BEARING: they make the server log
|
||||
# to stdout so the panel's Console live-tail (and the ready-regex) work, and
|
||||
# so the stdio admin adapter's command echoes are visible.
|
||||
exec stdbuf -oL -eL "${EXE}" Vein \
|
||||
-Port="${GAME_PORT}" \
|
||||
-QueryPort="${QUERY_PORT}" \
|
||||
-UseGameSaveDir \
|
||||
-stdout \
|
||||
-FullStdOutLogOutput
|
||||
@@ -0,0 +1,126 @@
|
||||
# VEIN — panel-native dedicated server module.
|
||||
#
|
||||
# Native Linux dedicated server. UE4-class survival game. SteamCMD app
|
||||
# 2131400 (the DEDICATED SERVER app id — distinct from the game app id
|
||||
# 1857950, which the server reports itself as to Steam via SteamAppId).
|
||||
# Anonymous Steam login works. Admin console is stdin-only (no network
|
||||
# RCON), so the panel drives the console via the stdio adapter — same
|
||||
# model as Enshrouded / Sons of the Forest.
|
||||
#
|
||||
# Reference: https://github.com/CubeCoders/AMPTemplates (vein.kvp)
|
||||
|
||||
id: vein
|
||||
name: "VEIN"
|
||||
version: 0.1.0
|
||||
authors:
|
||||
- panel contributors
|
||||
|
||||
supported_modes:
|
||||
- docker
|
||||
|
||||
runtime:
|
||||
docker:
|
||||
# Built locally from ./Dockerfile. Rebuild with:
|
||||
# docker build -t panel-vein:latest modules/vein
|
||||
image: panel-vein:latest
|
||||
# Host networking -- container shares the host net namespace, so
|
||||
# any port the game 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 below).
|
||||
network_mode: host
|
||||
browseable_root: /game-saves
|
||||
browseable_roots:
|
||||
- name: "Saves & Configs"
|
||||
path: /game-saves
|
||||
hint: "Vein/Saved/ savegame data + Config/LinuxServer/Game.ini"
|
||||
- name: "Game Files"
|
||||
path: /game
|
||||
hint: "Vein/Binaries/Linux/ server binary + steamclient libs"
|
||||
# Pre-declare every env key that any config_value below maps to. The
|
||||
# agent's resolve.go ONLY propagates config_values whose key is already
|
||||
# present in `docker.env` — keys not listed here get silently filtered
|
||||
# out. Empty-string defaults mean "leave Game.ini alone" (the entrypoint
|
||||
# stamps only non-empty values, so a recreate with empty env preserves
|
||||
# whatever the operator set previously via the Config/Files tab).
|
||||
env:
|
||||
# Steamworks client runtime init context — the dedicated-server app
|
||||
# (2131400) reports itself to Steam as VEIN (1857950). AMP sets this
|
||||
# too; without it the server's Steam socket init / master-server
|
||||
# listing fails.
|
||||
STEAM_APP_ID: "1857950"
|
||||
|
||||
# --- Per-instance ports ---
|
||||
# MUST be pre-declared here or resolve.go silently drops the allocator's
|
||||
# injected values (it only overrides docker.env keys that already exist),
|
||||
# and the entrypoint falls back to these defaults → two VEIN servers on
|
||||
# one host both bind 7777 and collide. The `env:` on each ports entry
|
||||
# below maps the panel-allocated HOST port into these vars at start.
|
||||
GAME_PORT: "7777"
|
||||
QUERY_PORT: "27015"
|
||||
RCON_PORT: "7878"
|
||||
|
||||
# --- Identity & access (stamped into Game.ini sections) ---
|
||||
SERVER_NAME: ""
|
||||
SERVER_DESCRIPTION: ""
|
||||
SERVER_PASSWORD: ""
|
||||
MAX_PLAYERS: ""
|
||||
SERVER_PUBLIC: "" # bPublic — list on the public browser
|
||||
HEARTBEAT_INTERVAL: ""
|
||||
ENABLE_VAC: "" # bVACEnabled under [OnlineSubsystemSteam]
|
||||
SHOW_SCOREBOARD_BADGES: ""
|
||||
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" }
|
||||
|
||||
# Multi-instance safety: `env:` tells the agent which container env var to
|
||||
# set with the allocated host port. The entrypoint reads these to build
|
||||
# VEIN's `-Port` / `-QueryPort` launch flags so two VEIN servers on the
|
||||
# same agent get distinct, non-colliding sockets (avoids the Valheim
|
||||
# hardcoded-port collision class of bug).
|
||||
ports:
|
||||
- { name: game, proto: udp, default: 7777, required: true, env: GAME_PORT }
|
||||
- { name: query, proto: udp, default: 27015, required: true, env: QUERY_PORT }
|
||||
# VEIN declares an RCON port but the admin path is STDIO; we expose it for
|
||||
# completeness / firewalling but do NOT wire a network rcon adapter to it.
|
||||
- { name: rcon, proto: udp, default: 7878, internal: true, env: RCON_PORT }
|
||||
|
||||
resources:
|
||||
min_ram_mb: 4096
|
||||
recommended_ram_mb: 8192
|
||||
|
||||
rcon:
|
||||
# VEIN admin console is stdin-only (no network RCON). The stdio adapter
|
||||
# auto-enables container OpenStdin (agent resolve.go) and the Console tab
|
||||
# "Send" box writes commands to the server process's stdin. Output comes
|
||||
# back via the docker-logs live tail, not a correlated reply.
|
||||
adapter: stdio
|
||||
# Log-derived player events. Regexes converted from CubeCoders'
|
||||
# AMPTemplates (vein.kvp) — .NET (?<n>) syntax -> Go (?P<n>).
|
||||
# Sourced-from-template; not yet exercised against a live client.
|
||||
events:
|
||||
join:
|
||||
pattern: 'LogNet: Login request: (?:\?Password=[^?]*)?\?Name=(?P<name>.+?)\?\?ID=(?P<id>\d+)\?Ticket='
|
||||
kind: join
|
||||
leave:
|
||||
pattern: 'LogNet: UChannel::CleanUp: ChIndex == \d+\. Closing connection\..*RemoteAddr: (?P<id>\d+):\d+'
|
||||
kind: leave
|
||||
|
||||
|
||||
update_providers:
|
||||
- id: stable
|
||||
kind: steamcmd
|
||||
app_id: "2131400"
|
||||
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: '/LogRamjetNetworking: Steamworks server initialized with SteamID \d+|LogRamjetNetworking: Heartbeating with IP /i'
|
||||
appearance:
|
||||
emoji: "🩸"
|
||||
grad: "linear-gradient(135deg,#2a0a0a,#7a1010)"
|
||||
art: "/game-art/vein.jpg"
|
||||
steam: "1857950"
|
||||
Reference in New Issue
Block a user