#!/bin/bash # panel-native Windrose entrypoint. # # Contract: # /game — SteamCMD-installed Windows build of Windrose (volume, # populated by the panel's SteamCMD updater sidecar). # If missing, exit with EX_CONFIG and a helpful message. # /game-saves — ServerDescription.json + R5/Saved/ tree (volume). # # Runs as root for its opening stage (fix ownership after the SteamCMD # sidecar wrote as root, nuke stale X lock, symlink /Logs + R5/Saved into # the save volume), then drops to the `panel` user for Wine + Xvfb + the # game. Wine refuses to run as root, so the drop is load-bearing. set -euo pipefail log() { printf '[panel-windrose] %s\n' "$*"; } # ----- stage 1: root-only bootstrap ----- if [[ "$(id -u)" = "0" ]]; then chown -R panel:panel /game /game-saves /home/panel 2>/dev/null || true # Remove Debian/Ubuntu's "127.0.1.1 " mapping from the # container's /etc/hosts (docker manages a per-container copy at # /var/lib/docker/containers//hosts, so this does NOT touch the # host file). # # Why: Windrose's UE5 / Wine ICE stack does gethostname() + # gethostbyname(), gets 127.0.1.1, then enumerates it as a host # ICE candidate. When a player joins via cloud P2P the server # tries to send STUN from 127.0.1.1 to the peer's relay, the # Linux kernel rejects with EACCES (loopback can't route # externally), Windrose retries a few times then crashes the # *server* process. Removing the mapping makes gethostbyname # fall through, ICE skips the loopback candidate, and joins work. # Note: /etc/hosts is a docker bind mount of the per-container # /var/lib/docker/containers//hosts; `sed -i` fails with # "Device or resource busy" on rename, so we filter then truncate- # write in place via grep|tee. # # Two-stage fix: # 1) Strip any "127.0.1.1 " line. # 2) Pin "127.0.0.1 " so NSS-files wins before DNS — # otherwise systemd-resolved on the host (which the container's # resolv.conf points at via 127.0.0.53 under network_mode=host) # synthesizes =>127.0.1.1 from thin air, undoing # step 1. With =>127.0.0.1 in /etc/hosts, ICE's # loopback filter actually catches it and the candidate is # dropped instead of becoming an EACCES STUN. if [[ -w /etc/hosts ]]; then HOSTNAME_VAL="$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null || echo localhost)" grep -vE '^127\.0\.1\.[0-9]+[[:space:]]' /etc/hosts > /tmp/hosts.new || true if [[ -s /tmp/hosts.new ]]; then cat /tmp/hosts.new > /etc/hosts fi if ! grep -qE "^127\\.0\\.0\\.1[[:space:]].*\\b${HOSTNAME_VAL}\\b" /etc/hosts; then printf '127.0.0.1 %s\n' "${HOSTNAME_VAL}" >> /etc/hosts fi rm -f /tmp/hosts.new fi # UE5 under Wine writes to Z:\Logs (= /Logs) and Z:\\Saved etc. # Pre-create /Logs as a symlink into the save volume so logs survive # container recreate and show up in the Files tab. mkdir -p /game-saves/WindroseLogs chown -R panel:panel /game-saves/WindroseLogs if [[ ! -e /Logs || -L /Logs ]]; then ln -sfn /game-saves/WindroseLogs /Logs fi 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 \ WINEPREFIX=/home/panel/.wine \ WINEDEBUG=-all \ SERVER_NAME="${SERVER_NAME:-panel Windrose}" \ DIRECT_PORT="${DIRECT_PORT:-7777}" \ USE_DIRECT_CONNECTION="${USE_DIRECT_CONNECTION:-true}" \ PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ /entrypoint.sh "$@" fi GAME_DIR=/game SAVE_DIR=/game-saves # --- locate the server binary --- # Windrose's StartServerForeground.bat launches the shipping binary directly: # start /abovenormal R5\Binaries\Win64\WindroseServer-Win64-Shipping.exe -log # The WindroseServer.exe at the game root is a ~260KB launcher stub, not the # real server. Prefer the shipping binary; fall back to any match if Windrose # changes their layout. CANDIDATES=( "${GAME_DIR}/R5/Binaries/Win64/WindroseServer-Win64-Shipping.exe" "${GAME_DIR}/R5/Binaries/WinGDK/WindroseServer-WinGDK-Shipping.exe" "${GAME_DIR}/R5/Binaries/Win64/WindroseServer.exe" ) DEDI_EXE="" for c in "${CANDIDATES[@]}"; do if [[ -f "$c" ]]; then DEDI_EXE="$c" break fi done if [[ -z "${DEDI_EXE}" ]]; then # Last-ditch: find any *-Shipping.exe (or WindroseServer.exe in a Binaries dir). found=$(find "${GAME_DIR}" -maxdepth 6 -iname "WindroseServer*.exe" -type f 2>/dev/null | grep -i "Shipping\|Binaries" | head -1 || true) if [[ -n "${found}" ]]; then DEDI_EXE="${found}" fi fi if [[ -z "${DEDI_EXE}" ]]; then log "ERROR: WindroseServer-Win64-Shipping.exe not found under ${GAME_DIR}/R5/Binaries/." log " Run 'Update' on this server in the panel — that launches a" log " SteamCMD sidecar which installs Windrose (Windows build) into" log " this volume via +@sSteamCmdForcePlatformType windows." exit 78 # EX_CONFIG fi log "found server binary: ${DEDI_EXE}" # --- persist Saves into /game-saves so SteamCMD validate can't clobber --- # UE5 writes to /R5/Saved/. Move the directory on first boot then # symlink. Rebooting finds the symlink already in place; idempotent. mkdir -p "${SAVE_DIR}/R5" if [[ -d "${GAME_DIR}/R5/Saved" && ! -L "${GAME_DIR}/R5/Saved" ]]; then if [[ ! -d "${SAVE_DIR}/R5/Saved" ]]; then mv "${GAME_DIR}/R5/Saved" "${SAVE_DIR}/R5/Saved" else rm -rf "${GAME_DIR}/R5/Saved" fi fi mkdir -p "${SAVE_DIR}/R5/Saved" mkdir -p "${GAME_DIR}/R5" ln -sfn "${SAVE_DIR}/R5/Saved" "${GAME_DIR}/R5/Saved" # --- ServerDescription.json: seed → migrate → symlink --- # Windrose 0.10.x generates ServerDescription.json at /game/R5/ on first # boot, which races our "migrate to save volume" step and leaves the file # unmigrated until the next restart (panel's Config tab reads from # /game-saves/R5/). Fix: seed a minimal stub in the save volume BEFORE # the game starts. The game opens the existing file, fills in empty # InviteCode / PersistentServerId / WorldIslandId on first save, and panel # reads a populated config immediately — no first-boot restart dance. # Template mirrors cytech-services AMP (windroseserverdescription.json). mkdir -p "${GAME_DIR}/R5" "${SAVE_DIR}/R5" DESC_GAME="${GAME_DIR}/R5/ServerDescription.json" DESC_SAVE="${SAVE_DIR}/R5/ServerDescription.json" if [[ -f "${DESC_GAME}" && ! -L "${DESC_GAME}" ]]; then # Legacy path: a prior boot wrote the file at /game/R5/ without migration. if [[ ! -s "${DESC_SAVE}" ]]; then log "migrating existing ServerDescription.json into save volume" mv "${DESC_GAME}" "${DESC_SAVE}" else rm -f "${DESC_GAME}" fi fi if [[ ! -s "${DESC_SAVE}" ]]; then log "seeding blank ServerDescription.json (game fills in InviteCode, WorldIslandId, PersistentServerId on first save)" cat > "${DESC_SAVE}" <<'JSON' { "Version": 1, "ServerDescription_Persistent": { "PersistentServerId": "", "InviteCode": "", "IsPasswordProtected": false, "Password": "", "ServerName": "panel Windrose", "Note": "", "WorldIslandId": "", "MaxPlayerCount": 8, "P2pProxyAddress": "127.0.0.1" } } JSON fi # Symlink /game/R5/ServerDescription.json → save-volume copy so the game # reads + writes the persistent file regardless of which path it opens. ln -sfn "${DESC_SAVE}" "${DESC_GAME}" # --- patch DirectConnectionServerPort + UseDirectConnection from env --- # Windrose reads its listening port from this JSON (no CLI flag honored). # We patch on every boot so a panel-allocated DIRECT_PORT flows through. # UseDirectConnection: true = listen on a public IP, players use IP+port. # false = use windrose.support cloud P2P (NAT-traversal # via TURN). Required when the host is on a LAN with # no port-forward, or for invite-code-only flows. # Default: true (matches the panel's typical "expose host networking + open # the port" deployment). Set USE_DIRECT_CONNECTION=false on a per-instance # basis (panel config_values) to switch to cloud P2P. USE_DIRECT_CONN_LOWER="$(printf '%s' "${USE_DIRECT_CONNECTION:-true}" | tr '[:upper:]' '[:lower:]')" case "${USE_DIRECT_CONN_LOWER}" in false|0|no|off) UDC_VAL="false" ;; *) UDC_VAL="true" ;; esac if [[ -s "${DESC_SAVE}" ]]; then log "patching ServerDescription.json (DirectConnectionServerPort=${DIRECT_PORT}, UseDirectConnection=${UDC_VAL})" if grep -q '"DirectConnectionServerPort"' "${DESC_SAVE}"; then sed -i -E "s|(\"DirectConnectionServerPort\"[[:space:]]*:[[:space:]]*)[0-9]+|\1${DIRECT_PORT}|" "${DESC_SAVE}" else # Insert before the closing brace of ServerDescription_Persistent. sed -i -E "s|(\"P2pProxyAddress\"[[:space:]]*:[[:space:]]*\"[^\"]*\")|\1,\n\t\t\"UseDirectConnection\": ${UDC_VAL},\n\t\t\"DirectConnectionServerPort\": ${DIRECT_PORT}|" "${DESC_SAVE}" fi if grep -q '"UseDirectConnection"' "${DESC_SAVE}"; then sed -i -E "s|(\"UseDirectConnection\"[[:space:]]*:[[:space:]]*)(true\|false)|\1${UDC_VAL}|" "${DESC_SAVE}" fi fi # Belt-and-suspenders watcher — catches the odd case where a future # Windrose build writes to /game/R5/ through the symlink's parent dir # instead of following the link. Exits once things are consistent; # bounded to 30 min to avoid leaking on crash paths. ( for _ in $(seq 1 360); do sleep 5 if [[ -L "${DESC_GAME}" ]] && [[ -s "${DESC_SAVE}" ]]; then exit 0; fi if [[ -f "${DESC_GAME}" ]] && [[ ! -L "${DESC_GAME}" ]]; then log "watcher: re-migrating ServerDescription.json (game wrote through the symlink parent?)" if [[ ! -s "${DESC_SAVE}" ]]; then mv "${DESC_GAME}" "${DESC_SAVE}" else rm -f "${DESC_GAME}" fi ln -sfn "${DESC_SAVE}" "${DESC_GAME}" exit 0 fi done ) & DESC_WATCHER_PID=$! # --- Wine prefix bootstrap (first boot, or repair after a partial init). xvfb-run # handles X for the real run; for prefix init we still need a display so # wineboot's installer dialogs auto-dismiss. --- NEED_INIT=0 if [[ ! -d "${WINEPREFIX}" || ! -f "${WINEPREFIX}/system.reg" ]]; then NEED_INIT=1 fi # A previous boot can leave a *partially* initialized prefix (system.reg # present but kernel32.dll missing) if wineboot was killed early — that # state crashes every subsequent start with "could not load kernel32.dll, # status c0000135" because our existing-prefix check passes. Detect and # repair by nuking the broken prefix and re-initing from scratch. if [[ "${NEED_INIT}" = 0 && ! -f "${WINEPREFIX}/drive_c/windows/system32/kernel32.dll" ]]; then log "wine prefix at ${WINEPREFIX} is missing kernel32.dll — wiping and reinitializing" rm -rf "${WINEPREFIX}" NEED_INIT=1 fi if [[ "${NEED_INIT}" = 1 ]]; then log "initializing Wine prefix at ${WINEPREFIX}" xvfb-run -a wineboot --init 2>&1 | sed 's/^/ /' || true wineserver -w || true if [[ ! -f "${WINEPREFIX}/drive_c/windows/system32/kernel32.dll" ]]; then log "WARNING: wineboot finished but kernel32.dll still missing — game start will fail" fi fi # --- go --- BIN_DIR="$(dirname "${DEDI_EXE}")" cd "${BIN_DIR}" log "starting Windrose dedicated server" log " game dir: ${GAME_DIR}" log " save dir: ${SAVE_DIR}" log " binary: ${DEDI_EXE}" log " bin dir: ${BIN_DIR}" log " port: ${DIRECT_PORT}" trap 'log "SIGTERM received; stopping server"; wineserver -k 2>/dev/null || true; kill "${DESC_WATCHER_PID:-}" 2>/dev/null || true; exit 0' TERM INT # Launch flags — matched to cytech-services' AMP template (windrose.kvp): # R5 project name (required positional for UE5 servers) # -stdout mirror UE5 log categories to stdout, not just R5.log # -FullStdOutLogOutput disable stdout filtering — the LogGlobalStatus ready # line we grep for is category-filtered otherwise and # only lands in R5.log. This flag is THE reason AMP's # AppReadyRegex ever matches. # -log enable the UE5 log file (R5/Saved/Logs/R5.log) too # -UNATTENDED / -NullRHI standard UE5 headless flags; harmless no-ops on this # build but documented in other UE5 dedicated patterns. # Port + server identity come from ServerDescription.json (we patch the # port above on every boot from $DIRECT_PORT). xvfb-run -a auto-allocates # a free X display — see panel/memory/gotchas.md for why we can't use :99. exec stdbuf -oL -eL xvfb-run -a --server-args="-screen 0 1024x768x24" \ wine "${DEDI_EXE}" \ R5 \ -stdout \ -FullStdOutLogOutput \ -log \ -UNATTENDED \ -NullRHI