Repo hygiene: remove the stale 'pi server' copy (v2.3; 'server' at v2.4 is canonical - a fix cut from the stale copy would revert the forged-BYE security fix), rescue its two unique design docs into server/, and gitignore the untracked personal pi-sender folder

This commit is contained in:
Ednunp
2026-07-23 15:47:38 +01:00
parent 81d19c7b8c
commit 527c2976c9
13 changed files with 3 additions and 1440 deletions
+3
View File
@@ -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/
-164
View File
@@ -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=<uuid> addr=1.2.3.4:5555 count=2
event=client_endpoint_update client_id=<uuid> old=1.2.3.4:5555 new=1.2.3.4:6666
event=client_named client_id=<uuid> name=Andre
event=client_left client_id=<uuid> addr=... reason=bye
event=client_idle_expired client_id=<uuid> addr=...
event=lobby_full attempted_client_id=<uuid> 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).
-1
View File
@@ -1 +0,0 @@
server-v2.3
-141
View File
@@ -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."
-13
View File
@@ -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
-358
View File
@@ -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 "$@"
-16
View File
@@ -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
-547
View File
@@ -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("<H", data, 6)[0]
sequence = struct.unpack_from("<I", data, 8)[0]
return pkt_type, stream_id, sequence
def parse_header_v2(data: bytes) -> 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("<H", data, 6)[0]
sequence = struct.unpack_from("<I", data, 8)[0]
client_id_bytes = bytes(data[V2_CLIENT_ID_OFFSET:V2_CLIENT_ID_OFFSET + V2_CLIENT_ID_LEN])
return pkt_type, stream_id, sequence, client_id_bytes
def _fmt_addr(addr: tuple[str, int]) -> 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("<H", header, 6, 0) # stream_id (unused)
struct.pack_into("<I", header, 8, 0) # sequence (unused)
header[V2_CLIENT_ID_OFFSET:V2_CLIENT_ID_OFFSET + V2_CLIENT_ID_LEN] = SERVER_CLIENT_ID_BYTES
payload = bytearray()
members = list(self.v2_clients.items())[:255] # 1-byte count
payload.append(len(members))
for cid, entry in members:
payload.extend(cid.bytes)
payload.extend(_encode_lobby_name(entry.display_name))
return bytes(header) + bytes(payload)
def _v2_broadcast_roster(self) -> 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("<H", header, 6, 0)
struct.pack_into("<I", header, 8, 0)
header[V2_CLIENT_ID_OFFSET:V2_CLIENT_ID_OFFSET + V2_CLIENT_ID_LEN] = SERVER_CLIENT_ID_BYTES
# Payload: 1 byte current count, 1 byte max count.
payload = bytes([len(self.v2_clients) & 0xFF, self.max_clients & 0xFF])
try:
self.sock.sendto(bytes(header) + payload, addr)
except OSError as e:
self.log.warning(
"event=send_failed proto=v2 reason=lobby_full to=%s err=%s",
_fmt_addr(addr), e,
)
self.log.info(
"event=lobby_full attempted_client_id=%s addr=%s count=%d max=%d",
attempted_client_id, _fmt_addr(addr),
len(self.v2_clients), self.max_clients,
)
self.stats.dropped_lobby_full += 1
def _v2_expire_idle(self, now: float) -> 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())
-14
View File
@@ -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
-123
View File
@@ -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
-63
View File
@@ -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."