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:
@@ -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/
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
server-v2.3
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -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 "$@"
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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."
|
||||
Reference in New Issue
Block a user