diff --git a/.gitignore b/.gitignore index 490a938..c48a6b3 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ Thumbs.db # Tokens / secrets — these should never be checked in .gh-token.txt gh-token.txt + +# Personal side-projects that must never ship to GitHub (untracked by design) +pi-sender/ diff --git a/pi server/README.md b/pi server/README.md deleted file mode 100644 index 5e725dd..0000000 --- a/pi server/README.md +++ /dev/null @@ -1,164 +0,0 @@ -# RemSound UDP Relay — `server-v2.0` - -A small UDP reflector that lets RemSound peers reach each other across the -internet without Tailscale in the audio path. Two modes in one binary: - -- **v1 (pairwise)** — the original two-slot reflector. First two endpoints - to send a valid RemSound v1 packet claim the slots; their traffic gets - mirrored to each other. Used by RemSound clients up to v1.x. -- **v2 (lobby)** — a multi-peer lobby (default cap: 10) keyed on a - per-instance CLIENT_ID. Used by RemSound clients that emit v2 packets. - Periodic LobbyRoster packets keep clients informed of who's in. - -A single relay instance handles both protocols concurrently on the same UDP -port. v1 clients keep working unchanged; v2 clients get the lobby model. -(A v1 client and a v2 client cannot hear each other in the same session; -that's a deliberate scope cut for this release.) - -## What's in this bundle - -| File | Purpose | -| ------------------------------------- | -------------------------------------------------------------------------------------------------- | -| `remsound-relay.py` | the relay itself (dual-protocol) | -| `remsound-relay.service` | systemd unit for the relay | -| `remsound-relay-update.sh` | auto-updater. Polls GitHub Releases hourly for newer `server-*` tags and installs them in place | -| `remsound-relay-update.service` | systemd one-shot unit fired by the timer | -| `remsound-relay-update.timer` | the schedule (boot + every hour, with random jitter) | -| `install.sh` | sets up the relay AND the auto-updater | -| `uninstall.sh` | removes everything this bundle installed | -| `smoke-test.sh` | post-install sanity check (v1 + v2 paths + updater scaffolding) | -| `VERSION` | the tag this bundle represents (`server-v2.0`) | -| `README.md` | this file | - -## Installing on a fresh host - -Tested on Raspberry Pi OS Bookworm and Debian / Ubuntu. Requires `python3` -and `curl` (both usually preinstalled). - -```bash -# 1. Download and extract the latest server tarball. -curl -L -o /tmp/remsound-server.tar.gz \ - https://github.com/Ednunp/RemSound/releases/download/server-v2.0/remsound-server-v2.0.tar.gz -tar xzf /tmp/remsound-server.tar.gz -C /tmp -cd /tmp/remsound-server-v2.0 - -# 2. Install — sets up the relay AND auto-updates. -sudo ./install.sh - -# 3. Open UDP 47830 in the firewall and / or router port-forward. -# On UFW: sudo ufw allow 47830/udp -# On UDM / pfSense / etc.: WAN UDP 47830 -> this host's LAN IP. - -# 4. Confirm it's alive. -sudo ./smoke-test.sh -``` - -After install, the auto-updater is enabled. Future releases roll out -automatically — no manual SCP, no manual edit. To pin to the current -version: - -```bash -sudo systemctl disable --now remsound-relay-update.timer -``` - -To check for updates manually: - -```bash -sudo systemctl start remsound-relay-update.service -``` - -To uninstall everything cleanly: - -```bash -cd /tmp/remsound-server-v2.0 # or wherever the bundle is -sudo ./uninstall.sh -``` - -## Files on disk after install - -| Path | Purpose | -| --------------------------------------------------- | -------------------------------------- | -| `/usr/local/sbin/remsound-relay.py` | the relay | -| `/usr/local/sbin/remsound-relay-update.sh` | the auto-updater | -| `/etc/systemd/system/remsound-relay.service` | relay unit | -| `/etc/systemd/system/remsound-relay-update.service` | updater unit | -| `/etc/systemd/system/remsound-relay-update.timer` | updater schedule | -| `/etc/remsound-relay/version` | currently installed tag | -| `/etc/remsound-relay/backup/` | snapshot for the updater's rollback | -| `/var/log/remsound-relay.log` | relay event log (`event=...` per line) | -| `/var/log/remsound-relay-update.log` | update-check history | - -## Networking - -- Listens on UDP `47830` (chosen to avoid clashes with RemSound's own - defaults — 47820/47821/47822 — plus NetFlow 2055 and NUT 3493). -- Bound to `0.0.0.0`, so the kernel routes via the default interface. - Tailscale must NOT carry this traffic — the whole point of the relay is - to remove Tailscale's hops from the audio path. -- The relay process itself never decodes audio. It validates the - RemSound header (magic `RMND`, version 1 or 2) and forwards or drops. - -## Log format - -Structured key=value lines. Notable events: - -``` -event=startup version_supported=v1,v2 listen=0.0.0.0:47830 max_clients=10 - -# v1 (pairwise) -event=peer_joined addr=1.2.3.4:5555 slots_filled=1 -event=peer_paired a=1.2.3.4:5555 b=5.6.7.8:9999 -event=peer_dropped reason=idle addr=1.2.3.4:5555 remaining=1 -event=peer_replaced old=1.2.3.4:5555 new=9.8.7.6:4444 - -# v2 (lobby) -event=client_joined client_id= addr=1.2.3.4:5555 count=2 -event=client_endpoint_update client_id= old=1.2.3.4:5555 new=1.2.3.4:6666 -event=client_named client_id= name=Andre -event=client_left client_id= addr=... reason=bye -event=client_idle_expired client_id= addr=... -event=lobby_full attempted_client_id= addr=... count=10 max=10 - -# once a minute -event=stats forwarded=N dropped_unpaired=N dropped_lobby_full=N - rejected_bad_header=N pair_changes=N lobby_changes=N - client_count=N v1_peers=[...] v2_clients=[...] -``` - -Never logs CLIENT_ID payload bytes beyond the UUID itself. Never logs audio -payload. - -## Tunables - -| Setting | How to override | -| ------------------------ | ------------------------------------------------------------------------ | -| Listen port (47830) | `ExecStart=` `--port=N` in the service unit | -| Listen address | `ExecStart=` `--host=X` in the service unit | -| Lobby capacity (10) | `--max-clients=N` or env var `REMSOUND_MAX_CLIENTS=N` in the service unit | -| Idle timeout (60 s) | edit `IDLE_TIMEOUT_SECONDS` in `remsound-relay.py` | -| Stats interval (60 s) | edit `STATS_INTERVAL_SECONDS` in `remsound-relay.py` | -| Update check (hourly) | edit `remsound-relay-update.timer` | -| Update repo (Ednunp/RemSound) | env var `REMSOUND_UPDATE_REPO` in the updater service unit | - -## Why an auto-updater - -The relay is small and (after this release) doesn't change often, but when -it does we'd rather not chase every operator to re-SCP. The updater polls -GitHub Releases for tags starting with `server-`, finds the highest version, -downloads it, swaps the files, restarts the service, and falls back to the -prior version if startup fails. Logs everything to -`/var/log/remsound-relay-update.log`. - -It only triggers on `server-*` tags, so RemSound client releases (`v1.x`, -`v2.x` without the `server-` prefix) don't affect the relay. - -## Disabling auto-updates - -Either disable the timer: - -```bash -sudo systemctl disable --now remsound-relay-update.timer -``` - -…or run `uninstall.sh` (removes the updater scaffolding entirely along -with the relay). diff --git a/pi server/VERSION b/pi server/VERSION deleted file mode 100644 index 53a63ce..0000000 --- a/pi server/VERSION +++ /dev/null @@ -1 +0,0 @@ -server-v2.3 diff --git a/pi server/install.sh b/pi server/install.sh deleted file mode 100644 index 4a8c508..0000000 --- a/pi server/install.sh +++ /dev/null @@ -1,141 +0,0 @@ -#!/bin/bash -# install.sh — RemSound UDP relay installer for a stock Raspberry Pi (or any -# systemd Linux). Idempotent: re-running it just refreshes the files and -# restarts the service. -# -# Run with sudo from inside this folder: -# sudo ./install.sh -# -# What it does: -# 1. Sanity checks: systemd present, python3 present, curl present. -# 2. Copies remsound-relay.py to /usr/local/sbin/. -# 3. Copies remsound-relay.service to /etc/systemd/system/. -# 4. Copies the auto-updater script + service + timer. -# 5. Creates empty log files. -# 6. Writes /etc/remsound-relay/version with the bundled tag. -# 7. Snapshots the current install to /etc/remsound-relay/backup/ so the -# auto-updater has somewhere to roll back to on a bad future release. -# 8. systemctl daemon-reload, enable + start the relay + updater timer. -# 9. Prints status and the last few log lines. - -set -euo pipefail - -if [[ $EUID -ne 0 ]]; then - echo "This installer must run as root. Try: sudo ./install.sh" >&2 - exit 1 -fi - -SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" - -REQUIRED=( - remsound-relay.py - remsound-relay.service - remsound-relay-update.sh - remsound-relay-update.service - remsound-relay-update.timer - VERSION -) -for f in "${REQUIRED[@]}"; do - if [[ ! -f "$SCRIPT_DIR/$f" ]]; then - echo "Bundle is missing $f. Run the installer from inside the unzipped bundle folder." >&2 - exit 1 - fi -done - -if ! command -v systemctl >/dev/null 2>&1; then - echo "systemctl not found. This bundle expects a systemd-based Linux." >&2 - exit 1 -fi -if ! command -v python3 >/dev/null 2>&1; then - echo "python3 not found. Install it first: sudo apt-get install -y python3" >&2 - exit 1 -fi -if ! command -v curl >/dev/null 2>&1; then - echo "curl not found. Install it first: sudo apt-get install -y curl" >&2 - exit 1 -fi - -VERSION_TAG="$(tr -d '[:space:]' < "$SCRIPT_DIR/VERSION")" -echo "Installing RemSound relay bundle $VERSION_TAG ..." - -echo "Installing relay script to /usr/local/sbin/remsound-relay.py ..." -install -o root -g root -m 755 "$SCRIPT_DIR/remsound-relay.py" /usr/local/sbin/remsound-relay.py -python3 -m py_compile /usr/local/sbin/remsound-relay.py - -echo "Installing systemd unit to /etc/systemd/system/remsound-relay.service ..." -install -o root -g root -m 644 "$SCRIPT_DIR/remsound-relay.service" /etc/systemd/system/remsound-relay.service - -echo "Installing auto-updater script to /usr/local/sbin/remsound-relay-update.sh ..." -install -o root -g root -m 755 "$SCRIPT_DIR/remsound-relay-update.sh" /usr/local/sbin/remsound-relay-update.sh - -echo "Installing auto-updater systemd units ..." -install -o root -g root -m 644 "$SCRIPT_DIR/remsound-relay-update.service" /etc/systemd/system/remsound-relay-update.service -install -o root -g root -m 644 "$SCRIPT_DIR/remsound-relay-update.timer" /etc/systemd/system/remsound-relay-update.timer - -echo "Ensuring log files exist ..." -touch /var/log/remsound-relay.log /var/log/remsound-relay-update.log -chown root:root /var/log/remsound-relay.log /var/log/remsound-relay-update.log -chmod 0644 /var/log/remsound-relay.log /var/log/remsound-relay-update.log - -echo "Writing version stamp ..." -mkdir -p /etc/remsound-relay /etc/remsound-relay/backup -printf '%s\n' "$VERSION_TAG" > /etc/remsound-relay/version - -echo "Snapshotting installed files to /etc/remsound-relay/backup/ for rollback ..." -rm -rf /etc/remsound-relay/backup -mkdir -p /etc/remsound-relay/backup -for f in \ - /usr/local/sbin/remsound-relay.py \ - /usr/local/sbin/remsound-relay-update.sh \ - /etc/systemd/system/remsound-relay.service \ - /etc/systemd/system/remsound-relay-update.service \ - /etc/systemd/system/remsound-relay-update.timer \ - /etc/remsound-relay/version -do - if [[ -f "$f" ]]; then - cp -a "$f" "/etc/remsound-relay/backup/$(basename "$f")" - fi -done - -echo "Reloading systemd ..." -systemctl daemon-reload - -echo "Enabling and starting remsound-relay.service ..." -systemctl enable remsound-relay.service >/dev/null -systemctl restart remsound-relay.service - -echo "Enabling and starting remsound-relay-update.timer ..." -systemctl enable remsound-relay-update.timer >/dev/null -systemctl restart remsound-relay-update.timer - -sleep 1 - -echo -echo "=== relay service status ===" -systemctl --no-pager --full status remsound-relay.service || true - -echo -echo "=== updater timer status ===" -systemctl --no-pager status remsound-relay-update.timer || true - -echo -echo "=== last 10 relay log lines ===" -tail -n 10 /var/log/remsound-relay.log 2>/dev/null || echo "(log empty so far)" - -echo -echo "=== listening socket check ===" -if command -v ss >/dev/null 2>&1; then - ss -lunp 2>/dev/null | grep ':47830' || echo "WARNING: no listener on UDP 47830 — see service status above." -else - echo "(ss not installed, skipping)" -fi - -echo -echo "Install complete. The relay is running on UDP 47830." -echo "Installed version: $VERSION_TAG" -echo "Auto-updates: enabled (hourly via remsound-relay-update.timer)." -echo -echo "To disable auto-updates: sudo systemctl disable --now remsound-relay-update.timer" -echo "To check for updates manually: sudo systemctl start remsound-relay-update.service" -echo "Update history log: /var/log/remsound-relay-update.log" -echo "See README.md for full operational notes." diff --git a/pi server/remsound-relay-update.service b/pi server/remsound-relay-update.service deleted file mode 100644 index cb2edf5..0000000 --- a/pi server/remsound-relay-update.service +++ /dev/null @@ -1,13 +0,0 @@ -[Unit] -Description=Check for and apply remsound-relay updates from GitHub -Documentation=file:///usr/local/sbin/remsound-relay-update.sh -After=network-online.target -Wants=network-online.target - -[Service] -Type=oneshot -ExecStart=/usr/local/sbin/remsound-relay-update.sh -Restart=no -# StandardOutput goes to the journal as well as the updater's log file. -StandardOutput=journal -StandardError=journal diff --git a/pi server/remsound-relay-update.sh b/pi server/remsound-relay-update.sh deleted file mode 100644 index 0dba8a3..0000000 --- a/pi server/remsound-relay-update.sh +++ /dev/null @@ -1,358 +0,0 @@ -#!/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 "$@" diff --git a/pi server/remsound-relay-update.timer b/pi server/remsound-relay-update.timer deleted file mode 100644 index f8ef231..0000000 --- a/pi server/remsound-relay-update.timer +++ /dev/null @@ -1,16 +0,0 @@ -[Unit] -Description=Periodic update check for remsound-relay - -[Timer] -# Fires 2 minutes after boot, then every hour, with a 10-minute random -# jitter so multiple relays don't all hit the GitHub API at the same -# instant. Persistent=true means missed runs (host was off) catch up on -# the next boot rather than silently skipping. -OnBootSec=2min -OnUnitActiveSec=1h -RandomizedDelaySec=10min -Persistent=true -Unit=remsound-relay-update.service - -[Install] -WantedBy=timers.target diff --git a/pi server/remsound-relay.py b/pi server/remsound-relay.py deleted file mode 100644 index 796d0f9..0000000 --- a/pi server/remsound-relay.py +++ /dev/null @@ -1,547 +0,0 @@ -#!/usr/bin/env python3 -""" -RemSound UDP relay, dual-protocol. - -Listens on a single UDP port and handles two protocol versions concurrently: - -- v1 ("pairwise"): 12-byte header, two-slot reflector. First two distinct - UDP endpoints to send a valid RemSound v1 packet claim the slots; subsequent - v1 packets from one slot's endpoint are reflected to the other. Slots idle - for IDLE_TIMEOUT_SECONDS are eligible for replacement. This is the original - remsound-relay.py behaviour, preserved here unchanged so legacy clients keep - working against the new server. - -- v2 ("lobby"): 28-byte header with embedded CLIENT_ID (UUID). Up to - REMSOUND_MAX_CLIENTS instances (default 10) form a single lobby. Each - incoming packet is forwarded unmodified to every OTHER registered client. - Identity is the CLIENT_ID, not the network endpoint — NAT rebinds and - same-NAT-multiple-clients are no longer special cases. Periodic LobbyRoster - packets keep clients informed of the current membership. - -The two protocols share state only via the listening socket and the stats -counters. They never interact otherwise: a v1 client and a v2 client cannot -hear each other in this release (deliberate — see the design doc). - -Owner: Pi thread. Spec: D:\\Dropbox\\proj\\pi\\remsound server update.md. -""" - -from __future__ import annotations - -import argparse -import logging -import logging.handlers -import os -import select -import signal -import socket -import struct -import sys -import time -import uuid -from dataclasses import dataclass, field -from typing import Optional - -LISTEN_HOST = "0.0.0.0" -DEFAULT_PORT = 47830 -RECV_BUFFER_BYTES = 2048 -IDLE_TIMEOUT_SECONDS = 60 -STATS_INTERVAL_SECONDS = 60 -ROSTER_HEARTBEAT_SECONDS = 1.0 # v2 only — periodic roster broadcast -SOCKET_POLL_TIMEOUT_SECONDS = 1.0 -DEFAULT_LOG_PATH = "/var/log/remsound-relay.log" -DEFAULT_MAX_CLIENTS = 10 -LOBBY_NAME_BYTES = 32 # bytes reserved for a display name on the wire - -# Wire format constants. -MAGIC = b"RMND" -V1_VERSION = 1 -V2_VERSION = 2 -V1_HEADER_LEN = 12 -V2_HEADER_LEN = 28 -V2_CLIENT_ID_OFFSET = 12 -V2_CLIENT_ID_LEN = 16 - -# Packet types (v1 + v2 shared range; v2-only types are 6+). -TYPE_FORMAT = 1 -TYPE_AUDIO = 2 -TYPE_KEEPALIVE = 3 -TYPE_HEARTBEAT = 4 -TYPE_CONTROL = 5 -TYPE_LOBBY_HELLO = 6 -TYPE_LOBBY_ROSTER = 7 -TYPE_LOBBY_FULL = 8 -TYPE_LOBBY_BYE = 9 -V2_FORWARDABLE_TYPES = { - TYPE_FORMAT, TYPE_AUDIO, TYPE_KEEPALIVE, TYPE_HEARTBEAT, TYPE_CONTROL, -} - -# A zero UUID identifies the server in outbound v2 packets that we originate -# (LobbyRoster, LobbyFull, LobbyBye-from-server). Clients can recognise this -# as "from server" rather than from another peer. -SERVER_CLIENT_ID_BYTES = b"\x00" * V2_CLIENT_ID_LEN - - -@dataclass -class PeerSlot: - """v1 protocol — one of (up to) two peer endpoints in a pair.""" - addr: tuple[str, int] - last_seen: float - rx_packets: int = 0 - tx_packets: int = 0 - - -@dataclass -class ClientEntry: - """v2 protocol — one client in the lobby, keyed by CLIENT_ID.""" - addr: tuple[str, int] - display_name: str - last_seen: float - rx_packets: int = 0 - tx_packets: int = 0 - - -@dataclass -class RelayStats: - forwarded: int = 0 - dropped_unpaired: int = 0 # v1: third endpoint while pair active - dropped_lobby_full: int = 0 # v2: 11th client when at cap - rejected_bad_header: int = 0 - pair_changes: int = 0 # v1 slot joins/leaves/replacements - lobby_changes: int = 0 # v2 joins/leaves/expiries - - -def setup_logger(log_path: str) -> logging.Logger: - logger = logging.getLogger("remsound-relay") - logger.setLevel(logging.INFO) - fmt = logging.Formatter( - fmt="%(asctime)s level=%(levelname)s %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", - ) - try: - fh = logging.handlers.WatchedFileHandler(log_path, encoding="utf-8") - fh.setFormatter(fmt) - logger.addHandler(fh) - except OSError as e: - sys.stderr.write(f"remsound-relay: could not open {log_path}: {e}\n") - sh = logging.StreamHandler(sys.stderr) - sh.setFormatter(fmt) - logger.addHandler(sh) - return logger - - -def parse_header_v1(data: bytes) -> Optional[tuple[int, int, int]]: - """Validate a v1 header. Returns (type, stream_id, sequence) or None.""" - if len(data) < V1_HEADER_LEN: - return None - pkt_type = data[5] - stream_id = struct.unpack_from(" Optional[tuple[int, int, int, bytes]]: - """Validate a v2 header. Returns (type, stream_id, sequence, client_id_bytes) or None.""" - if len(data) < V2_HEADER_LEN: - return None - pkt_type = data[5] - stream_id = struct.unpack_from(" str: - return f"{addr[0]}:{addr[1]}" - - -def _decode_lobby_name(raw: bytes) -> str: - """Decode the 32-byte null-padded UTF-8 display-name field. Tolerant of garbage.""" - end = raw.find(b"\x00") - if end >= 0: - raw = raw[:end] - try: - return raw.decode("utf-8", errors="replace").strip() - except Exception: - return "" - - -def _encode_lobby_name(name: str) -> bytes: - """Encode a display name into LOBBY_NAME_BYTES, null-padded.""" - encoded = (name or "").encode("utf-8", errors="replace")[:LOBBY_NAME_BYTES] - return encoded + b"\x00" * (LOBBY_NAME_BYTES - len(encoded)) - - -class Relay: - """Dispatcher that owns both the v1 pair state and the v2 lobby state.""" - - def __init__(self, sock: socket.socket, log: logging.Logger, max_clients: int): - self.sock = sock - self.log = log - self.max_clients = max_clients - # v1 state - self.v1_peers: list[PeerSlot] = [] - # v2 state - self.v2_clients: dict[uuid.UUID, ClientEntry] = {} - self.v2_roster_dirty = False # set when membership changes - self.v2_last_roster_broadcast = 0.0 - # shared - self.stats = RelayStats() - self.last_stats_log = time.monotonic() - - # ------- v1 (pairwise) ------------------------------------------------- - - def _v1_find_slot(self, addr: tuple[str, int]) -> Optional[int]: - for i, p in enumerate(self.v1_peers): - if p.addr == addr: - return i - return None - - def _v1_expire_idle(self, now: float) -> None: - if not self.v1_peers: - return - kept: list[PeerSlot] = [] - dropped: list[tuple[str, int]] = [] - for p in self.v1_peers: - if (now - p.last_seen) <= IDLE_TIMEOUT_SECONDS: - kept.append(p) - else: - dropped.append(p.addr) - if dropped: - self.v1_peers = kept - for addr in dropped: - self.log.info( - "event=peer_dropped reason=idle addr=%s remaining=%d", - _fmt_addr(addr), len(self.v1_peers), - ) - self.stats.pair_changes += 1 - - def _v1_admit_or_replace(self, addr: tuple[str, int], now: float) -> int: - if len(self.v1_peers) < 2: - self.v1_peers.append(PeerSlot(addr=addr, last_seen=now)) - self.log.info( - "event=peer_joined addr=%s slots_filled=%d", - _fmt_addr(addr), len(self.v1_peers), - ) - self.stats.pair_changes += 1 - if len(self.v1_peers) == 2: - self.log.info( - "event=peer_paired a=%s b=%s", - _fmt_addr(self.v1_peers[0].addr), - _fmt_addr(self.v1_peers[1].addr), - ) - return len(self.v1_peers) - 1 - oldest = 0 if self.v1_peers[0].last_seen <= self.v1_peers[1].last_seen else 1 - if (now - self.v1_peers[oldest].last_seen) > IDLE_TIMEOUT_SECONDS: - old_addr = self.v1_peers[oldest].addr - self.v1_peers[oldest] = PeerSlot(addr=addr, last_seen=now) - self.log.info( - "event=peer_replaced old=%s new=%s", - _fmt_addr(old_addr), _fmt_addr(addr), - ) - self.stats.pair_changes += 1 - return oldest - return -1 - - def _handle_v1(self, data: bytes, addr: tuple[str, int]) -> None: - if parse_header_v1(data) is None: - self.stats.rejected_bad_header += 1 - return - now = time.monotonic() - idx = self._v1_find_slot(addr) - if idx is None: - self._v1_expire_idle(now) - idx = self._v1_admit_or_replace(addr, now) - if idx < 0: - self.stats.dropped_unpaired += 1 - return - peer = self.v1_peers[idx] - peer.last_seen = now - peer.rx_packets += 1 - if len(self.v1_peers) == 2: - other = self.v1_peers[1 - idx] - try: - self.sock.sendto(data, other.addr) - other.tx_packets += 1 - self.stats.forwarded += 1 - except OSError as e: - self.log.warning( - "event=send_failed proto=v1 to=%s err=%s", - _fmt_addr(other.addr), e, - ) - else: - self.stats.dropped_unpaired += 1 - - # ------- v2 (lobby) ---------------------------------------------------- - - def _v2_build_roster_packet(self) -> bytes: - """Build a LobbyRoster packet with the current membership.""" - # Use a separate per-build sequence — clients can ignore it; we use 0. - header = bytearray(V2_HEADER_LEN) - header[0:4] = MAGIC - header[4] = V2_VERSION - header[5] = TYPE_LOBBY_ROSTER - struct.pack_into(" None: - if not self.v2_clients: - self.v2_roster_dirty = False - self.v2_last_roster_broadcast = time.monotonic() - return - packet = self._v2_build_roster_packet() - for entry in self.v2_clients.values(): - try: - self.sock.sendto(packet, entry.addr) - except OSError as e: - self.log.warning( - "event=send_failed proto=v2 reason=roster to=%s err=%s", - _fmt_addr(entry.addr), e, - ) - self.v2_roster_dirty = False - self.v2_last_roster_broadcast = time.monotonic() - - def _v2_send_lobby_full(self, attempted_client_id: uuid.UUID, addr: tuple[str, int]) -> None: - """Send a LobbyFull packet back to an over-cap client and log it.""" - header = bytearray(V2_HEADER_LEN) - header[0:4] = MAGIC - header[4] = V2_VERSION - header[5] = TYPE_LOBBY_FULL - struct.pack_into(" None: - if not self.v2_clients: - return - expired: list[uuid.UUID] = [] - for cid, entry in self.v2_clients.items(): - if (now - entry.last_seen) > IDLE_TIMEOUT_SECONDS: - expired.append(cid) - for cid in expired: - entry = self.v2_clients.pop(cid) - self.log.info( - "event=client_idle_expired client_id=%s addr=%s", - cid, _fmt_addr(entry.addr), - ) - self.stats.lobby_changes += 1 - self.v2_roster_dirty = True - - def _handle_v2(self, data: bytes, addr: tuple[str, int]) -> None: - parsed = parse_header_v2(data) - if parsed is None: - self.stats.rejected_bad_header += 1 - return - pkt_type, _stream_id, _sequence, cid_bytes = parsed - try: - client_id = uuid.UUID(bytes=cid_bytes) - except ValueError: - self.stats.rejected_bad_header += 1 - return - now = time.monotonic() - entry = self.v2_clients.get(client_id) - if entry is None: - # Admit attempt. - if len(self.v2_clients) >= self.max_clients: - self._v2_send_lobby_full(client_id, addr) - return - entry = ClientEntry(addr=addr, display_name="", last_seen=now) - self.v2_clients[client_id] = entry - self.log.info( - "event=client_joined client_id=%s addr=%s count=%d", - client_id, _fmt_addr(addr), len(self.v2_clients), - ) - self.stats.lobby_changes += 1 - self.v2_roster_dirty = True - else: - # Refresh endpoint (handles NAT rebind) and last-seen. - if entry.addr != addr: - self.log.info( - "event=client_endpoint_update client_id=%s old=%s new=%s", - client_id, _fmt_addr(entry.addr), _fmt_addr(addr), - ) - entry.addr = addr - entry.last_seen = now - entry.rx_packets += 1 - - # Type-specific handling. - if pkt_type == TYPE_LOBBY_HELLO: - payload = data[V2_HEADER_LEN:V2_HEADER_LEN + LOBBY_NAME_BYTES] - new_name = _decode_lobby_name(payload) - if new_name != entry.display_name: - entry.display_name = new_name - self.log.info( - "event=client_named client_id=%s name=%r", client_id, new_name, - ) - self.v2_roster_dirty = True - return - if pkt_type == TYPE_LOBBY_BYE: - self.v2_clients.pop(client_id, None) - self.log.info( - "event=client_left client_id=%s addr=%s reason=bye", - client_id, _fmt_addr(addr), - ) - self.stats.lobby_changes += 1 - self.v2_roster_dirty = True - return - if pkt_type not in V2_FORWARDABLE_TYPES: - # Unknown / server-originated type from a client. Ignore quietly. - return - - # Fan-out forwarding to every OTHER client. - for other_id, other in self.v2_clients.items(): - if other_id == client_id: - continue - try: - self.sock.sendto(data, other.addr) - other.tx_packets += 1 - self.stats.forwarded += 1 - except OSError as e: - self.log.warning( - "event=send_failed proto=v2 to=%s err=%s", - _fmt_addr(other.addr), e, - ) - - # ------- shared -------------------------------------------------------- - - def handle_packet(self, data: bytes, addr: tuple[str, int]) -> None: - if len(data) < 6 or data[0:4] != MAGIC: - self.stats.rejected_bad_header += 1 - return - version = data[4] - if version == V1_VERSION: - self._handle_v1(data, addr) - elif version == V2_VERSION: - self._handle_v2(data, addr) - else: - self.stats.rejected_bad_header += 1 - - def tick(self, now: float) -> None: - """Periodic housekeeping: idle expiry + roster broadcast.""" - self._v1_expire_idle(now) - self._v2_expire_idle(now) - if self.v2_clients and ( - self.v2_roster_dirty - or (now - self.v2_last_roster_broadcast) >= ROSTER_HEARTBEAT_SECONDS - ): - self._v2_broadcast_roster() - - def maybe_log_stats(self, now: float) -> None: - if (now - self.last_stats_log) < STATS_INTERVAL_SECONDS: - return - self.last_stats_log = now - s = self.stats - v1_summary = ", ".join( - f"{_fmt_addr(p.addr)}(rx={p.rx_packets},tx={p.tx_packets})" - for p in self.v1_peers - ) or "none" - v2_summary = ", ".join( - f"{cid}@{_fmt_addr(e.addr)}(rx={e.rx_packets},tx={e.tx_packets})" - for cid, e in self.v2_clients.items() - ) or "none" - self.log.info( - "event=stats forwarded=%d dropped_unpaired=%d dropped_lobby_full=%d " - "rejected_bad_header=%d pair_changes=%d lobby_changes=%d " - "client_count=%d v1_peers=[%s] v2_clients=[%s]", - s.forwarded, s.dropped_unpaired, s.dropped_lobby_full, - s.rejected_bad_header, s.pair_changes, s.lobby_changes, - len(self.v2_clients), v1_summary, v2_summary, - ) - self.stats = RelayStats() - for p in self.v1_peers: - p.rx_packets = 0 - p.tx_packets = 0 - for e in self.v2_clients.values(): - e.rx_packets = 0 - e.tx_packets = 0 - - -def main() -> int: - parser = argparse.ArgumentParser(description="RemSound UDP relay (dual-protocol v1+v2)") - parser.add_argument("--port", type=int, default=DEFAULT_PORT, - help=f"UDP port to listen on (default {DEFAULT_PORT})") - parser.add_argument("--host", default=LISTEN_HOST, - help=f"Bind address (default {LISTEN_HOST})") - parser.add_argument("--log-path", default=DEFAULT_LOG_PATH, - help=f"Log file path (default {DEFAULT_LOG_PATH})") - parser.add_argument( - "--max-clients", type=int, - default=int(os.environ.get("REMSOUND_MAX_CLIENTS", str(DEFAULT_MAX_CLIENTS))), - help=f"v2 lobby capacity (default {DEFAULT_MAX_CLIENTS}, " - "overridable via REMSOUND_MAX_CLIENTS env var)", - ) - args = parser.parse_args() - if args.max_clients < 2: - sys.stderr.write("remsound-relay: --max-clients must be >= 2\n") - return 2 - - log = setup_logger(args.log_path) - log.info( - "event=startup version_supported=v1,v2 listen=%s:%d max_clients=%d", - args.host, args.port, args.max_clients, - ) - - sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - try: - sock.bind((args.host, args.port)) - except OSError as e: - log.error("event=bind_failed err=%s", e) - return 1 - - relay = Relay(sock, log, args.max_clients) - stop_flag = {"stop": False} - - def _stop_signal(_signum, _frame): - stop_flag["stop"] = True - - signal.signal(signal.SIGTERM, _stop_signal) - signal.signal(signal.SIGINT, _stop_signal) - - try: - while not stop_flag["stop"]: - try: - ready, _, _ = select.select([sock], [], [], SOCKET_POLL_TIMEOUT_SECONDS) - except InterruptedError: - continue - now = time.monotonic() - if ready: - try: - data, addr = sock.recvfrom(RECV_BUFFER_BYTES) - except OSError as e: - log.warning("event=recv_failed err=%s", e) - continue - relay.handle_packet(data, addr) - relay.tick(now) - relay.maybe_log_stats(now) - finally: - log.info("event=shutdown") - sock.close() - - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/pi server/remsound-relay.service b/pi server/remsound-relay.service deleted file mode 100644 index fe7f060..0000000 --- a/pi server/remsound-relay.service +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=RemSound UDP relay -Documentation=file:///usr/local/sbin/remsound-relay.py -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -ExecStart=/usr/bin/python3 /usr/local/sbin/remsound-relay.py -Restart=on-failure -RestartSec=5 - -[Install] -WantedBy=multi-user.target diff --git a/pi server/smoke-test.sh b/pi server/smoke-test.sh deleted file mode 100644 index b47fd40..0000000 --- a/pi server/smoke-test.sh +++ /dev/null @@ -1,123 +0,0 @@ -#!/bin/bash -# smoke-test.sh — Quick health check for the RemSound relay + auto-updater. -# -# Run after install. Confirms the relay service is running, listening on -# UDP 47830, accepts both a v1 and a v2 valid RemSound header, and that -# the auto-updater scaffolding is in place. - -set -u - -GREEN=$'\033[0;32m' -RED=$'\033[0;31m' -YELLOW=$'\033[0;33m' -RESET=$'\033[0m' - -ok() { printf "%s[ok]%s %s\n" "$GREEN" "$RESET" "$*"; } -fail() { printf "%s[FAIL]%s %s\n" "$RED" "$RESET" "$*"; } -warn() { printf "%s[warn]%s %s\n" "$YELLOW" "$RESET" "$*"; } - -failures=0 - -# 1. Relay service active? -if systemctl is-active --quiet remsound-relay.service; then - ok "remsound-relay.service is active" -else - fail "remsound-relay.service is not active. Try: sudo systemctl status remsound-relay" - failures=$((failures + 1)) -fi - -# 2. Listening on UDP 47830? -if command -v ss >/dev/null 2>&1; then - if ss -lun 2>/dev/null | grep -q ':47830'; then - ok "listening on UDP 47830" - else - fail "no UDP 47830 listener visible to ss" - failures=$((failures + 1)) - fi -else - warn "ss not installed, skipping listener check" -fi - -# 3. Send synthetic valid v1 + v2 RemSound headers, confirm they're accepted. -if command -v python3 >/dev/null 2>&1; then - python3 - <<'PY' -import socket, struct, uuid -s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - -# v1: magic 'RMND' (LE 'RMND'), version 1, type 4 (Heartbeat), stream 1, seq 1. -v1 = bytes([0x52, 0x4D, 0x4E, 0x44, 1, 4, 1, 0, 1, 0, 0, 0]) -s.sendto(v1, ("127.0.0.1", 47830)) - -# v2: magic 'RMND', version 2, type 4 (Heartbeat), stream 1, seq 1, random UUID. -cid = uuid.uuid4().bytes -v2 = bytes([0x52, 0x4D, 0x4E, 0x44, 2, 4, 1, 0, 1, 0, 0, 0]) + cid -s.sendto(v2, ("127.0.0.1", 47830)) - -s.close() -PY - ok "sent synthetic v1 + v2 RemSound headers to 127.0.0.1:47830" -else - warn "python3 not installed, skipping header send" -fi - -# 4. Look for the expected log events for both protocols. -sleep 1 -read_log() { - if [[ -r /var/log/remsound-relay.log ]]; then - tail -40 /var/log/remsound-relay.log 2>/dev/null - elif sudo -n test -r /var/log/remsound-relay.log 2>/dev/null; then - sudo tail -40 /var/log/remsound-relay.log 2>/dev/null - fi -} -log_lines="$(read_log)" -if [[ -n "$log_lines" ]]; then - if printf '%s\n' "$log_lines" | grep -q "event=peer_joined.*127.0.0.1"; then - ok "v1 path: relay logged a peer_joined event for 127.0.0.1" - else - warn "v1 path: no peer_joined event for 127.0.0.1 in last 40 log lines" - fi - if printf '%s\n' "$log_lines" | grep -q "event=client_joined.*127.0.0.1"; then - ok "v2 path: relay logged a client_joined event for 127.0.0.1" - else - warn "v2 path: no client_joined event for 127.0.0.1 in last 40 log lines" - fi -else - warn "cannot read /var/log/remsound-relay.log (try: sudo $0)" -fi - -# 5. Auto-updater scaffolding in place? -if [[ -x /usr/local/sbin/remsound-relay-update.sh ]]; then - ok "auto-updater script at /usr/local/sbin/remsound-relay-update.sh" -else - fail "missing /usr/local/sbin/remsound-relay-update.sh" - failures=$((failures + 1)) -fi -if systemctl is-active --quiet remsound-relay-update.timer; then - ok "remsound-relay-update.timer is active" -else - fail "remsound-relay-update.timer is not active. Try: sudo systemctl status remsound-relay-update.timer" - failures=$((failures + 1)) -fi -if [[ -r /etc/remsound-relay/version ]]; then - installed_tag="$(tr -d '[:space:]' < /etc/remsound-relay/version)" - if [[ "$installed_tag" =~ ^server-v[0-9]+\.[0-9]+ ]]; then - ok "installed version: $installed_tag" - else - warn "version file present but unexpected format: $installed_tag" - fi -elif sudo -n test -r /etc/remsound-relay/version 2>/dev/null; then - installed_tag="$(sudo tr -d '[:space:]' < /etc/remsound-relay/version)" - ok "installed version (via sudo): $installed_tag" -else - fail "cannot read /etc/remsound-relay/version" - failures=$((failures + 1)) -fi - -echo -if (( failures == 0 )); then - echo "${GREEN}All checks passed.${RESET}" - exit 0 -else - echo "${RED}${failures} check(s) failed.${RESET}" - exit 1 -fi diff --git a/pi server/uninstall.sh b/pi server/uninstall.sh deleted file mode 100644 index 668a773..0000000 --- a/pi server/uninstall.sh +++ /dev/null @@ -1,63 +0,0 @@ -#!/bin/bash -# uninstall.sh — Remove the RemSound UDP relay and its auto-updater cleanly. -# -# Run with sudo: -# sudo ./uninstall.sh -# -# Stops and disables both the relay and the updater timer, removes their -# scripts and unit files, and removes the /etc/remsound-relay/ state dir -# (version file + rollback backups). Leaves log files in place — rename or -# delete them yourself if you don't want them kept. Does NOT touch your -# router port-forward — that's separate. - -set -euo pipefail - -if [[ $EUID -ne 0 ]]; then - echo "This uninstaller must run as root. Try: sudo ./uninstall.sh" >&2 - exit 1 -fi - -# Stop the updater timer first so it can't fire mid-uninstall. -if systemctl list-unit-files remsound-relay-update.timer >/dev/null 2>&1; then - echo "Stopping remsound-relay-update.timer ..." - systemctl stop remsound-relay-update.timer 2>/dev/null || true - systemctl disable remsound-relay-update.timer 2>/dev/null || true -fi - -if systemctl list-unit-files remsound-relay-update.service >/dev/null 2>&1; then - systemctl stop remsound-relay-update.service 2>/dev/null || true -fi - -if systemctl list-unit-files remsound-relay.service >/dev/null 2>&1; then - echo "Stopping remsound-relay.service ..." - systemctl stop remsound-relay.service 2>/dev/null || true - echo "Disabling remsound-relay.service ..." - systemctl disable remsound-relay.service 2>/dev/null || true -fi - -for f in \ - /etc/systemd/system/remsound-relay.service \ - /etc/systemd/system/remsound-relay-update.service \ - /etc/systemd/system/remsound-relay-update.timer \ - /usr/local/sbin/remsound-relay.py \ - /usr/local/sbin/remsound-relay-update.sh -do - if [[ -f "$f" ]]; then - echo "Removing $f ..." - rm -f "$f" - fi -done - -if [[ -d /etc/remsound-relay ]]; then - echo "Removing /etc/remsound-relay/ (version stamp + backup) ..." - rm -rf /etc/remsound-relay -fi - -systemctl daemon-reload - -echo -echo "Uninstall complete." -echo "Note: log files left in place — delete them yourself if you don't want them:" -echo " /var/log/remsound-relay.log" -echo " /var/log/remsound-relay-update.log" -echo "Note: any router port-forward you added for UDP 47830 is unchanged — remove that yourself if you no longer need it." diff --git a/pi server/pi server update.md b/server/pi server update.md similarity index 100% rename from pi server/pi server update.md rename to server/pi server update.md diff --git a/pi server/remsound server update.md b/server/remsound server update.md similarity index 100% rename from pi server/remsound server update.md rename to server/remsound server update.md