From 10df575ef72481117866eb81f691915b465a1aa3 Mon Sep 17 00:00:00 2001 From: Ednunp Date: Fri, 15 May 2026 17:24:27 +0100 Subject: [PATCH] =?UTF-8?q?server:=20update=20to=20v2.3=20=E2=80=94=20dual?= =?UTF-8?q?-protocol=20relay=20(v1=20pair=20+=20v2=20lobby)=20+=20auto-upd?= =?UTF-8?q?ater=20with=20EXIT=20trap=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/remsound-relay-update.sh | 358 ++++++++++++++++++++++++++++++++ 1 file changed, 358 insertions(+) create mode 100644 server/remsound-relay-update.sh diff --git a/server/remsound-relay-update.sh b/server/remsound-relay-update.sh new file mode 100644 index 0000000..0dba8a3 --- /dev/null +++ b/server/remsound-relay-update.sh @@ -0,0 +1,358 @@ +#!/bin/bash +# remsound-relay-update.sh — periodic GitHub-release-based updater for the +# RemSound relay. Designed to run from a systemd .timer; safe to run by hand +# too. Idempotent: if there is no newer release, exits 0 quickly with no +# system changes. +# +# What it does: +# 1. Read the installed tag from /etc/remsound-relay/version. +# 2. Query the GitHub Releases API for tags starting with "server-". +# 3. If the latest is newer than the installed tag: +# a. Download the matching tarball asset. +# b. Snapshot current installed files to /etc/remsound-relay/backup/. +# c. Stop the relay service. +# d. Replace the relay files with the new tarball contents. +# e. systemctl daemon-reload + start. +# f. Wait 3s and check the service is active. +# If yes: write the new tag, log success, exit 0. +# If no: restore from backup, restart, log failure, exit 1. +# 4. Log everything to /var/log/remsound-relay-update.log. +# +# Failure modes are defensive — a broken updater run leaves the previously +# installed version running, never less. + +set -euo pipefail + +# -------- configuration ------------------------------------------------------ + +REPO="${REMSOUND_UPDATE_REPO:-Ednunp/RemSound}" +TAG_PREFIX="${REMSOUND_UPDATE_TAG_PREFIX:-server-}" +ASSET_PATTERN="${REMSOUND_UPDATE_ASSET_PATTERN:-remsound-server-.*\.tar\.gz$}" + +INSTALL_BIN="/usr/local/sbin/remsound-relay.py" +INSTALL_UPDATER="/usr/local/sbin/remsound-relay-update.sh" +INSTALL_SERVICE="/etc/systemd/system/remsound-relay.service" +INSTALL_UPDATE_SERVICE="/etc/systemd/system/remsound-relay-update.service" +INSTALL_UPDATE_TIMER="/etc/systemd/system/remsound-relay-update.timer" + +STATE_DIR="/etc/remsound-relay" +VERSION_FILE="$STATE_DIR/version" +BACKUP_DIR="$STATE_DIR/backup" +LOG_FILE="/var/log/remsound-relay-update.log" + +SERVICE_NAME="remsound-relay.service" +HEALTH_WAIT_SECONDS=3 + +# Script-global mktemp working directory. Set by main(); cleaned up by the +# EXIT trap below. Kept at script scope (not function-local) so the trap can +# still see it under `set -u` after main returns. +WORK_DIR="" + +cleanup_work_dir() { + if [[ -n "$WORK_DIR" && -d "$WORK_DIR" ]]; then + rm -rf "$WORK_DIR" + fi +} +trap cleanup_work_dir EXIT + +# -------- helpers ------------------------------------------------------------ + +log() { + local ts msg + ts="$(date '+%Y-%m-%d %H:%M:%S')" + msg="$ts $*" + printf '%s\n' "$msg" | tee -a "$LOG_FILE" >&2 +} + +require_root() { + if [[ $EUID -ne 0 ]]; then + log "ERROR: this script must run as root" + exit 1 + fi +} + +ensure_dirs() { + mkdir -p "$STATE_DIR" "$BACKUP_DIR" "$(dirname "$LOG_FILE")" + touch "$LOG_FILE" +} + +read_current_version() { + if [[ -f "$VERSION_FILE" ]]; then + local v + v="$(tr -d '[:space:]' < "$VERSION_FILE")" + if [[ -n "$v" ]]; then + printf '%s' "$v" + return + fi + fi + printf '%s' "${TAG_PREFIX}v0" +} + +# Parse a tag like "server-v2.10" into a comparable numeric form. +# Outputs MAJOR.MINOR; both default to 0 if the tag is unparseable. +tag_to_version() { + local tag="$1" + # strip the prefix + tag="${tag#"$TAG_PREFIX"}" + # strip a leading "v" if present + tag="${tag#v}" + local major minor + major="${tag%%.*}" + minor="${tag#*.}" + # if there's no dot, minor==major. Treat as MAJOR.0. + if [[ "$minor" == "$tag" ]]; then + minor="0" + fi + # keep only digits — survive things like "v2.0-rc1" by ignoring the suffix. + major="${major//[^0-9]/}" + minor="${minor//[^0-9]/}" + : "${major:=0}" + : "${minor:=0}" + printf '%s.%s' "$major" "$minor" +} + +# Returns 0 if $1 > $2 (i.e. left tag is newer), 1 otherwise. +tag_newer_than() { + local left right lv rv + left="$(tag_to_version "$1")" + right="$(tag_to_version "$2")" + # Numeric compare major then minor. + lv="${left%.*}"; rv="${right%.*}" + if (( lv > rv )); then return 0; fi + if (( lv < rv )); then return 1; fi + lv="${left#*.}"; rv="${right#*.}" + if (( lv > rv )); then return 0; fi + return 1 +} + +# -------- GitHub releases query --------------------------------------------- + +# Fetch the releases list and pick the latest server-tag release. +# Outputs three lines: tag, asset_url, asset_name. Exits non-zero on no match. +get_latest_release() { + local json + if ! json="$(curl --fail --silent --show-error --max-time 30 \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${REPO}/releases?per_page=30" 2>&1)"; then + log "ERROR: failed to query GitHub releases: $json" + return 2 + fi + + REPO="$REPO" TAG_PREFIX="$TAG_PREFIX" ASSET_PATTERN="$ASSET_PATTERN" \ + python3 - "$json" <<'PY' +import json, os, re, sys + +raw = sys.argv[1] if len(sys.argv) > 1 else "" +prefix = os.environ.get("TAG_PREFIX", "server-") +asset_re = re.compile(os.environ.get("ASSET_PATTERN", r"remsound-server-.*\.tar\.gz$")) + +try: + releases = json.loads(raw) +except Exception as exc: + sys.stderr.write(f"json parse failed: {exc}\n") + sys.exit(3) + +if not isinstance(releases, list): + sys.stderr.write(f"unexpected releases response shape\n") + sys.exit(3) + +def parse_version(tag): + # strip prefix + if tag.startswith(prefix): + tag = tag[len(prefix):] + if tag.startswith("v"): + tag = tag[1:] + parts = tag.split(".") + out = [] + for p in parts: + digits = "".join(c for c in p if c.isdigit()) + out.append(int(digits) if digits else 0) + if len(out) < 2: + out.append(0) + return tuple(out) + +candidates = [] +for r in releases: + if not isinstance(r, dict): + continue + tag = r.get("tag_name") or "" + if not tag.startswith(prefix): + continue + if r.get("draft") or r.get("prerelease"): + continue + asset = None + for a in r.get("assets") or []: + name = (a or {}).get("name") or "" + if asset_re.search(name): + asset = a + break + if asset is None: + continue + url = asset.get("browser_download_url") or "" + name = asset.get("name") or "" + if not url: + continue + candidates.append((parse_version(tag), tag, url, name)) + +if not candidates: + sys.stderr.write("no eligible server-* releases found\n") + sys.exit(4) + +candidates.sort(reverse=True) +_, tag, url, name = candidates[0] +print(tag) +print(url) +print(name) +PY +} + +# -------- backup + install --------------------------------------------------- + +snapshot_backup() { + log "snapshotting current install to $BACKUP_DIR" + rm -rf "$BACKUP_DIR" + mkdir -p "$BACKUP_DIR" + for f in \ + "$INSTALL_BIN" "$INSTALL_UPDATER" \ + "$INSTALL_SERVICE" "$INSTALL_UPDATE_SERVICE" "$INSTALL_UPDATE_TIMER" \ + "$VERSION_FILE" + do + if [[ -f "$f" ]]; then + cp -a "$f" "$BACKUP_DIR/$(basename "$f")" + fi + done +} + +restore_backup() { + log "rolling back from $BACKUP_DIR" + for f in \ + "$INSTALL_BIN" "$INSTALL_UPDATER" \ + "$INSTALL_SERVICE" "$INSTALL_UPDATE_SERVICE" "$INSTALL_UPDATE_TIMER" \ + "$VERSION_FILE" + do + local backup="$BACKUP_DIR/$(basename "$f")" + if [[ -f "$backup" ]]; then + cp -a "$backup" "$f" + fi + done + systemctl daemon-reload + systemctl start "$SERVICE_NAME" || true +} + +install_from_staging() { + local staging="$1" + # Required files: remsound-relay.py + remsound-relay.service. + if [[ ! -f "$staging/remsound-relay.py" ]]; then + log "ERROR: staging missing remsound-relay.py" + return 1 + fi + install -o root -g root -m 755 "$staging/remsound-relay.py" "$INSTALL_BIN" + python3 -m py_compile "$INSTALL_BIN" + if [[ -f "$staging/remsound-relay.service" ]]; then + install -o root -g root -m 644 "$staging/remsound-relay.service" "$INSTALL_SERVICE" + fi + if [[ -f "$staging/remsound-relay-update.sh" ]]; then + install -o root -g root -m 755 "$staging/remsound-relay-update.sh" "$INSTALL_UPDATER" + fi + if [[ -f "$staging/remsound-relay-update.service" ]]; then + install -o root -g root -m 644 "$staging/remsound-relay-update.service" "$INSTALL_UPDATE_SERVICE" + fi + if [[ -f "$staging/remsound-relay-update.timer" ]]; then + install -o root -g root -m 644 "$staging/remsound-relay-update.timer" "$INSTALL_UPDATE_TIMER" + fi + return 0 +} + +# -------- main flow ---------------------------------------------------------- + +main() { + require_root + ensure_dirs + + log "update check starting (repo=$REPO prefix=$TAG_PREFIX)" + + local current latest_tag asset_url asset_name + current="$(read_current_version)" + log "currently installed: $current" + + local release_info + if ! release_info="$(get_latest_release)"; then + log "no upgrade attempted (could not query releases or no eligible release)" + return 0 + fi + latest_tag="$(printf '%s\n' "$release_info" | sed -n '1p')" + asset_url="$(printf '%s\n' "$release_info" | sed -n '2p')" + asset_name="$(printf '%s\n' "$release_info" | sed -n '3p')" + log "latest available: $latest_tag asset=$asset_name" + + if ! tag_newer_than "$latest_tag" "$current"; then + log "up to date (installed $current >= available $latest_tag)" + return 0 + fi + + log "newer release found: $latest_tag -> upgrading from $current" + + # Working area in /tmp. Use the script-global $WORK_DIR (not a function + # local) so the EXIT trap can still see the variable after main returns. + # The trap is also script-global, registered just below. + WORK_DIR="$(mktemp -d -t remsound-relay-update.XXXXXXXX)" + + local tarball="$WORK_DIR/$asset_name" + log "downloading $asset_url" + if ! curl --fail --silent --show-error --max-time 120 \ + --location -o "$tarball" "$asset_url"; then + log "ERROR: download failed" + return 1 + fi + + log "extracting $asset_name" + if ! tar -xzf "$tarball" -C "$WORK_DIR"; then + log "ERROR: tarball extraction failed" + return 1 + fi + # Find the staging root — first directory inside the work dir. + local staging + staging="$(find "$WORK_DIR" -mindepth 1 -maxdepth 1 -type d | head -n 1)" + if [[ -z "$staging" ]]; then + log "ERROR: tarball did not contain a top-level folder" + return 1 + fi + log "staging at $staging" + + snapshot_backup + + log "stopping $SERVICE_NAME" + systemctl stop "$SERVICE_NAME" || true + + if ! install_from_staging "$staging"; then + log "ERROR: install step failed — rolling back" + restore_backup + return 1 + fi + + # Persist the new version BEFORE starting, so a crash after start still + # leaves the version file consistent with what's on disk. + printf '%s\n' "$latest_tag" > "$VERSION_FILE" + + log "reloading systemd + starting $SERVICE_NAME" + systemctl daemon-reload + systemctl start "$SERVICE_NAME" || true + + sleep "$HEALTH_WAIT_SECONDS" + + if systemctl is-active --quiet "$SERVICE_NAME"; then + log "post-install check: $SERVICE_NAME is active — upgrade to $latest_tag complete" + return 0 + fi + + log "post-install check FAILED: $SERVICE_NAME not active — rolling back" + restore_backup + if systemctl is-active --quiet "$SERVICE_NAME"; then + log "rollback succeeded — back on $current" + else + log "ERROR: rollback also did not restore service — manual intervention required" + fi + return 1 +} + +main "$@"