Files
RemSound/server/remsound-relay-update.sh
EdnunpandClaude Opus 4.8 84c4a47411 v3.9.1: plain/WASAPI streams now play on an ASIO-mode receiver (silent-mic fix)
Receiver: a Mixed (plain) session is rendered on an active lane in BothIndependent mode instead of being skipped, so a WASAPI-only sender is no longer silent to a receiver that has an ASIO driver selected. Also port the per-session buffer depth-drain (stops the receive jitter buffer bloating).

Sender: add sndAudFr meter (audio frames actually sent) to localise capture vs send.

App: startup sound cue (machine-wide, Preferences); stop sending audio when no peer is reachable (issue #8). Version 3.9.1.

Server (relay): fix updater version-compare for multi-dot tags, guard the main loop against crashes, reject spoofed BYE from a mismatched endpoint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 19:00:33 +01:00

364 lines
12 KiB
Bash

#!/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"
}
# Compare two release tags by their dotted numeric version, parsed the SAME way as the Python
# release selector in get_latest_release() — split on every '.', keep the digits of each
# component, then pad to equal length before comparing. Delegated to python3 (already a hard
# dependency of this script) so the upgrade GATE and the release SELECTOR can never disagree, and
# so multi-component tags (server-v2.3.1), pre-release suffixes (server-v2.3-rc1) and missing
# components are all handled correctly.
#
# This replaces a bash-only parser that collapsed everything after the FIRST dot into the "minor"
# field and then stripped the dot — so server-v2.3.1 read as "2.31" and was wrongly judged NEWER
# than server-v2.3. The moment any patch-style tag existed, the hourly update check would STOP and
# RESTART the live relay (a real multi-second outage for every connected client), and it could even
# "upgrade" to an OLDER build (server-v2.9.1 -> "2.91" > server-v2.10 -> "2.10"). 2026-06-12.
# Returns 0 if $1 is a strictly newer version than $2, 1 otherwise (equal counts as NOT newer).
tag_newer_than() {
TAG_PREFIX="$TAG_PREFIX" python3 - "$1" "$2" <<'PY'
import os, sys
prefix = os.environ.get("TAG_PREFIX", "server-")
def parse(tag):
if tag.startswith(prefix):
tag = tag[len(prefix):]
if tag.startswith("v"):
tag = tag[1:]
out = []
for part in tag.split("."):
digits = "".join(c for c in part if c.isdigit())
out.append(int(digits) if digits else 0)
return out
left = parse(sys.argv[1])
right = parse(sys.argv[2])
# Pad to equal length so 2.3 and 2.3.0 compare EQUAL — a shorter tuple must not read as older,
# or a re-tagged same-version release would trigger a needless stop/restart of the relay.
n = max(len(left), len(right))
left += [0] * (n - len(left))
right += [0] * (n - len(right))
sys.exit(0 if left > right else 1)
PY
}
# -------- 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 "$@"