From 8312873c0d056520a5152091a06047bffc9df451 Mon Sep 17 00:00:00 2001 From: rope Date: Fri, 11 Sep 2026 17:07:23 +0100 Subject: [PATCH] valheim: restart only when a new Steam build ships Co-Authored-By: Claude Opus 5 --- common.nix | 1 + services/valheim-autoupdate.nix | 124 ++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 services/valheim-autoupdate.nix diff --git a/common.nix b/common.nix index 3091343..fd21958 100644 --- a/common.nix +++ b/common.nix @@ -125,6 +125,7 @@ in ./services/redlib.nix ./services/hardware-health.nix ./services/wan-watchdog.nix + ./services/valheim-autoupdate.nix ./services/prebuild.nix ]; diff --git a/services/valheim-autoupdate.nix b/services/valheim-autoupdate.nix new file mode 100644 index 0000000..9073489 --- /dev/null +++ b/services/valheim-autoupdate.nix @@ -0,0 +1,124 @@ +# services/valheim-autoupdate.nix — restart the Valheim server only when Steam +# actually ships a new build. +# +# The Pelican egg already has AUTO_UPDATE=1, so steamcmd runs on every boot and +# a restart *is* the update. The missing half is knowing when to bother, which +# a plain nightly restart doesn't answer (it reboots players for nothing 364 +# nights out of 365). So, hourly: +# +# 1. installed buildid <- steamapps/appmanifest_896660.acf in the server volume +# 2. latest buildid <- the container's own steamcmd, public branch +# 3. differ, nobody playing -> POST restart to the wings daemon API +# +# Why the wings API and not `docker restart`: the egg's stop command is ^C, so +# wings sends SIGINT and Valheim saves the world before exiting. docker's +# SIGTERM would not. Auth reuses the node token already in /etc/pelican/config.yml +# — read at *runtime*, so eval stays pure (see CLAUDE.md); no new secret, and +# nothing to rotate by hand. +# +# Valheim has no console, no RCON and no broadcast command, so players cannot be +# warned in-game — the only honest options are "restart on an empty server" or +# "kick people mid-raid". This does the former and pushes an ntfy note instead. +{ config, lib, pkgs, ... }: +let + # Pelican server "hawk" (panel server id 3). Both the volume path and the + # container name are this uuid — wings names containers after the server. + serverUuid = "185008d8-8a04-4077-be78-63853dc06c0e"; + steamAppId = "896660"; # valheim_server; 892970 is the client + gamePort = "25569"; + + autoupdate = pkgs.writeShellScript "valheim-autoupdate" '' + set -uo pipefail + + uuid=${serverUuid} + appid=${steamAppId} + manifest=/var/lib/pelican/volumes/$uuid/steamapps/appmanifest_$appid.acf + wings_cfg=/etc/pelican/config.yml + state=/var/lib/valheim-autoupdate/announced + secret=/var/secrets/ntfy-url + + docker=${pkgs.docker}/bin/docker + awk=${pkgs.gawk}/bin/awk + curl=${pkgs.curl}/bin/curl + + notify() { # body + [ -f "$secret" ] || return 0 + url=$(${pkgs.coreutils}/bin/tr -d '\n' < "$secret") + $curl -fsS --max-time 10 \ + -H "Title: Valheim server" -H "Tags: shield" \ + -d "$1" "$url" >/dev/null 2>&1 || true + } + + # Nothing to restart, and no steamcmd to ask — a stopped server updates + # itself whenever it next boots anyway. + [ "$($docker inspect -f '{{.State.Running}}' "$uuid" 2>/dev/null)" = "true" ] || exit 0 + + installed=$($awk -F'"' '/"buildid"/{print $4; exit}' "$manifest" 2>/dev/null) + latest=$($docker exec "$uuid" \ + ./steamcmd/steamcmd.sh +login anonymous +app_info_update 1 \ + +app_info_print "$appid" +quit 2>/dev/null \ + | $awk '/"branches"/{b=1} b && /"public"/{p=1} p && /"buildid"/{gsub(/"/,"",$2); print $2; exit}') + + # Steam unreachable, rate-limited, or output we don't recognise: do nothing + # rather than guess. Next hour will try again. + case "$latest" in ""|*[!0-9]*) exit 0 ;; esac + case "$installed" in ""|*[!0-9]*) exit 0 ;; esac + [ "$installed" = "$latest" ] && exit 0 + + # ponytail: UDP conntrack entries for the game port are the cheap stand-in + # for a player count — no A2S challenge handshake to parse. They linger + # ~2min past the last packet, so this reads as "played very recently", + # which errs toward leaving people alone. Upgrade path if that's ever too + # coarse: query A2S_INFO on the query port (game port + 1) and read the + # players byte. + if ${pkgs.gnugrep}/bin/grep -q "dport=${gamePort} " /proc/net/nf_conntrack 2>/dev/null; then + if [ "$(${pkgs.coreutils}/bin/cat "$state" 2>/dev/null || true)" != "$latest" ]; then + notify "Valheim build $installed -> $latest is out. Someone is online, so the update waits until the server is empty." + ${pkgs.coreutils}/bin/printf '%s' "$latest" > "$state" + fi + echo "valheim-autoupdate: $installed -> $latest available, players connected, deferring" + exit 0 + fi + + token=$($awk '/^token:/{print $2}' "$wings_cfg") + if [ -z "$token" ]; then + echo "valheim-autoupdate: no wings token in $wings_cfg, cannot restart" >&2 + exit 1 + fi + + echo "valheim-autoupdate: $installed -> $latest, server empty, restarting" + if $curl -fsS --max-time 30 -X POST \ + -H "Authorization: Bearer $token" -H 'Content-Type: application/json' \ + -d '{"action":"restart"}' \ + "http://127.0.0.1:8443/api/servers/$uuid/power" >/dev/null; then + notify "Restarted for Valheim build $installed -> $latest." + ${pkgs.coreutils}/bin/printf '%s' "$latest" > "$state" + else + echo "valheim-autoupdate: wings power request failed" >&2 + exit 1 + fi + ''; +in +{ + config = lib.mkIf (config.networking.hostName == "FredOS-Mediaserver") { + systemd.services.valheim-autoupdate = { + description = "Restart the Valheim server when a new Steam build ships"; + after = [ "docker-pelican-wings.service" ]; + serviceConfig = { + Type = "oneshot"; + ExecStart = autoupdate; + StateDirectory = "valheim-autoupdate"; + # steamcmd's app_info_update round-trip is the slow part (~1min worst case). + TimeoutStartSec = "5min"; + }; + }; + systemd.timers.valheim-autoupdate = { + wantedBy = [ "timers.target" ]; + timerConfig = { + OnBootSec = "10min"; # let wings bring the game container up first + OnUnitActiveSec = "1h"; + RandomizedDelaySec = "5min"; # don't hit Steam on the exact hour + }; + }; + }; +}