Volume/pan/EQ tab overhaul: one master switch, peer checklist, 16-band parametric EQ

Reworks the per-peer shaping tab (held for next release):

 * Renamed the tab to "Volume, pan and EQ for peers"; the Preferences toggle now defaults ON.
 * Collapsed the two master switches (Enable EQ / Enable pan) into ONE: "Enable volume, pan and
   EQ for all peers" (Alt+E). Volume now obeys it too. PeerDspChain.Build takes a single enabled
   flag; Profile.EnableAllPeerShaping replaces the two bools (old ones kept for load-migration).
 * Peer picker is now a CheckedListBox: ticking a peer shapes them (per-peer bypass via new
   PeerShaping.Enabled, default true); the focused row is the one the controls edit. Effective
   shaping = master switch AND that peer's tick. Letter-nav suppressed so keys never toggle a tick.
 * Three EQ modes, renamed: "3 band simple EQ", "12 band advanced graphic EQ", and the new
   "16 band parametric EQ" (PeerEqMode.Parametric16Band).
 * Parametric EQ: up to 16 user bands, each a boost/cut across a start->end range (PeerShaping
   .ParametricBands; ParametricToPeaking maps range -> peaking centre+Q, shared by DSP and curve).
   Add band dialog (spin-or-type, numeric-only, live preview, OK/Escape); Bands list sorted
   bass->treble reading "X Hz to Y Hz, plus/minus N dB"; Delete key / Delete button, multi-select.
   Set peer EQ to default clears the parametric list too.
 * dB now spoken as words ("plus 3 dB" / "minus 6 dB" / "flat") on the graphic sliders and the
   parametric list, since NVDA users typically have punctuation off and never hear a "+".
 * New unbound machine-wide global shortcut "Toggle volume, pan and EQ for all peers" (not stored
   in any profile) via the hotkey controller + settings store.
 * Renamed the Inputs/outputs "Set volume for all received audio" to "Master receive volume".
 * Added EqCurveControl: a purely-visual EQ response graph (not focusable, invisible to NVDA).
 * Full manual sweep (readme.html + regenerated MANUAL.md).

Build clean; --selftest passes. Deployed to both test folders. Held for next release.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-07 23:21:18 +01:00
co-authored by Claude Opus 4.8
parent eaae76d015
commit 033edd776f
102 changed files with 4658 additions and 135 deletions
+164
View File
@@ -0,0 +1,164 @@
# 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
@@ -0,0 +1 @@
server-v2.3
+141
View File
@@ -0,0 +1,141 @@
#!/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."
+199
View File
@@ -0,0 +1,199 @@
# RemSound Pi Server — Handover
**Status: server side COMPLETE.** Built, released, deployed, verified — 15 May 2026.
This document records the upgrade of the RemSound relay server from the original
two-peer reflector to a dual-protocol (v1 pairwise + v2 lobby) relay with a
GitHub-based auto-updater. It is written for the RemSound thread, for Andre, and
for any future maintainer.
The work was done from the **Pi thread** (the one that manages Ed's Raspberry Pi).
The design it was built from is the companion file `remsound server update.md`.
---
## 1. What was built
A single relay binary, `remsound-relay.py`, that handles **two protocol versions
concurrently** on the same UDP listener (port 47830):
- **v1 (pairwise)** — the original two-slot reflector, **unchanged**. First two
UDP endpoints to send a valid RemSound v1 packet claim the slots; their
traffic is mirrored to each other. Existing RemSound clients (v1.x) keep
working against the new server with no changes.
- **v2 (lobby)** — a multi-peer lobby, up to 10 peers (configurable), keyed on a
per-instance `CLIENT_ID` (UUID). Every packet from one client is fanned out to
every other client in the lobby. NAT rebinds and "two clients behind one NAT"
stop being special cases because identity is the CLIENT_ID, not the endpoint.
Periodic `LobbyRoster` packets keep clients informed of membership.
The server inspects byte 4 of each packet (the version byte) and routes v1 vs v2
accordingly. A v1 client and a v2 client **cannot** share a lobby in this release
— that is a deliberate scope cut (see the design doc).
Also built:
- **Auto-updater** — `remsound-relay-update.sh`, run by a systemd timer. It polls
the GitHub Releases API hourly for tags beginning `server-`, and if a newer one
exists, downloads it, backs up the current install, swaps the files, restarts
the service, health-checks it, and **rolls back automatically** if the service
fails to come up.
- **systemd units** — `remsound-relay-update.service` (one-shot) +
`remsound-relay-update.timer` (boot + 2 min, then hourly with 10-min jitter).
- **Installer / uninstaller / smoke-test** — `install.sh`, `uninstall.sh`,
`smoke-test.sh`, updated to wire in the auto-updater.
- **`VERSION`** — the tag the bundle represents.
---
## 2. Where everything is
| Thing | Location |
| --- | --- |
| GitHub releases (what the auto-updater pulls) | `github.com/Ednunp/RemSound/releases` — tags `server-v2.0``server-v2.3` |
| GitHub source | `github.com/Ednunp/RemSound``server/` folder |
| Deployed and running | Ed's Raspberry Pi (`Pi5`), `server-v2.3`, UDP 47830 |
| Deployable bundle (this archive) | the files alongside this document |
| Design / spec | `remsound server update.md` (alongside this document) |
**Note for the RemSound thread:** the repo's `server/` folder was updated by the
Pi thread on 15 May 2026 directly via the GitHub API. The **local checkout** at
`D:\proj\remsound\server\` may therefore be behind the remote — do a `git pull`
to sync before doing any work there, and do **not** overwrite `server/` with
older server code.
---
## 3. The releases
| Tag | What it is |
| --- | --- |
| `server-v2.0` | Initial dual-protocol release |
| `server-v2.1` | No-op test release (used to validate the auto-updater's upgrade path) |
| `server-v2.2` | Bug fix — see section 6 |
| `server-v2.3` | No-op test release (validated the fixed updater) |
The Pi runs `server-v2.3`. The auto-updater always picks the **highest** version,
so the test releases don't interfere. **Future real releases should be `server-v2.4`
and upward.**
---
## 4. IMPORTANT — what is still left, and whose job it is
The **server is finished**. What remains is **client-side work in the RemSound
application**, and that is the **RemSound thread's job**, not the Pi thread's.
Per the design doc, sections 5 and 9, the client work is:
1. `Profile` / `AppConfig`: a new `ClientId` (Guid) field, generated once and
persisted in `remsound.config.json`.
2. `RemPacket`: header read/write learns the v2 format (28-byte header with the
16-byte CLIENT_ID). v1 read/write stays.
3. `AudioSender`: send one stream to the lobby server; drop the per-peer fan-out.
4. `AudioReceiver` / `StreamSession`: re-key sessions on `(CLIENT_ID, streamId)`
instead of `(IPEndPoint, streamId)`.
5. `Connectivity` tab: a "Lobby" section showing the current `LobbyRoster`.
None of this blocks anything: v1 clients keep working against the new server, so
the client work can land in its own release whenever convenient (the design doc
suggests `v1.5` or `v2.0` of the client).
---
## 5. Wire format (v1 vs v2)
**v1 header — 12 bytes, unchanged:**
```
0 4 MAGIC = 'RMND'
4 1 VERSION = 1
5 1 TYPE (Format=1, Audio=2, KeepAlive=3, Heartbeat=4, Control=5)
6 2 STREAM_ID (LE)
8 4 SEQUENCE (LE)
12 payload...
```
**v2 header — 28 bytes:**
```
0 4 MAGIC = 'RMND'
4 1 VERSION = 2
5 1 TYPE (1-5 as v1, plus LobbyHello=6, LobbyRoster=7,
LobbyFull=8, LobbyBye=9)
6 2 STREAM_ID (LE)
8 4 SEQUENCE (LE)
12 16 CLIENT_ID (UUID, RFC 4122 binary form)
28 payload...
```
v2 packet types the server originates use a zero CLIENT_ID
(`00000000-0000-0000-0000-000000000000`) so clients can recognise "from server".
---
## 6. The bug we hit (for the record)
During release validation, the auto-updater (`remsound-relay-update.sh`) was
found to exit with status 1 **after a successful upgrade**. Cause: the EXIT trap
referenced a variable (`work`) that had been declared `local` inside a function;
by the time bash fired the EXIT trap (after the function returned) the variable
was out of scope, so `set -u` raised `unbound variable` and bash exited 1. systemd
then marked the one-shot service as failed even though the upgrade had completed
correctly.
Fixed in `server-v2.2`: the working-directory variable was promoted to script
scope (`WORK_DIR`) and the cleanup trap registered at script-global level. The
clean upgrade path was re-verified on `server-v2.2``server-v2.3`.
---
## 7. Cutting a future server release
```bash
# 1. Edit the bundle source. Bump VERSION to the new tag, e.g. "server-v2.4".
# 2. Tar it with the correct internal directory name:
tar -czf /tmp/remsound-server-v2.4.tar.gz \
--transform 's,^<srcdir>,remsound-server-v2.4,' <srcdir>
# 3. Publish the release:
gh release create server-v2.4 /tmp/remsound-server-v2.4.tar.gz \
--repo Ednunp/RemSound --title "Server v2.4 — ..." --notes "..."
# 4. Within ~1 hour every running relay's auto-updater picks it up,
# installs it, restarts, and rolls back automatically if it fails.
```
The asset must be named `remsound-server-*.tar.gz` and must contain a single
top-level folder. The updater finds the highest `server-*` tag, so version
numbers must keep climbing.
---
## 8. Installing on a fresh box (e.g. Andre's Linux server)
The deployable files sit alongside this document. On the target machine:
```bash
sudo ./install.sh # sets up the relay AND the auto-updater
sudo ./smoke-test.sh # confirms it's alive
# then open UDP 47830 in the firewall / router toward this host
```
After that the box auto-updates from GitHub — no manual intervention ever again.
To pin a version: `sudo systemctl disable --now remsound-relay-update.timer`.
To remove everything: `sudo ./uninstall.sh`.
Full operational detail is in the bundle's own `README.md`.
---
## 9. Verification done
- Initial install on the Pi via `install.sh` — smoke test all green.
- v1 synthetic packet accepted (logged `peer_joined`); v2 synthetic packet
accepted (logged `client_joined`).
- Auto-updater "up to date" path — clean exit.
- Auto-updater upgrade path — `server-v2.0``v2.1``v2.2``v2.3`, each
step verified, post-fix runs exit `0/SUCCESS`, version stamp updates, rollback
snapshot in place.
- The relay survived a whole-house power cut on 15 May 2026 and came back on
`server-v2.3` automatically.
+480
View File
@@ -0,0 +1,480 @@
# RemSound relay upgrade — lobby model with auto-updates
Design + ops handover for upgrading the existing `remsound-relay.py` from a two-slot pairing reflector to a small lobby-style multi-peer relay. Same bundle works on a Raspberry Pi (the existing deployment) and on a full Linux host (Andre's box). Includes a self-update mechanism so once a host is on the new bundle, future releases roll out without anyone manually copying files.
The existing two-slot relay (`server/remsound-relay.py` on GitHub) stays usable. The lobby relay is a parallel script — same package, additional file — so existing hosts that just want a private two-peer relay keep working unmodified.
---
## 1. What's changing and why
### Current state
`remsound-relay.py` has two peer slots. The first two UDP endpoints to send a valid RemSound packet claim the slots; everything from one slot is reflected to the other. Third endpoint is dropped silently. Slots get reclaimed after 60 s idle.
This means:
- Hard cap of two connected peers per relay.
- Two clients behind the same NAT can both register (different ephemeral ports), but they get paired with each other instead of with the intended remote peer.
- A third RemSound instance joining an active pair gets ignored with no feedback.
### What we want
- Any number of RemSound instances (up to a configurable cap, default 10) all able to be in the same conversation through one server.
- Each instance is identified by a stable client ID, not by its network endpoint. NAT rebinds, network switches, and same-NAT-multiple-clients all stop being special cases.
- The relay forwards each packet to every other registered client. No mixing on the server. Clients use `PlayoutEngine`'s existing per-session mixing exactly as they do today.
- The relay logs joins, leaves, and per-minute stats. It never decodes audio and never logs payload bytes.
- The relay auto-updates: when a new release is published on GitHub, every running relay picks it up within an hour and restarts itself.
### What we're not adding (yet)
- Rooms / channels. One server = one lobby. Want a second lobby? Run a second instance on a different port.
- Server-side mixing. Forwarding is simpler, faster, and matches RemSound's existing client-side mix path.
- Voice activation / push-to-talk gating at the server.
- Authentication or lobby passwords.
All of those are future work and don't need to block this change.
---
## 2. Lobby model
### Capacity
- Cap = 10 clients per lobby. Configurable on the server (`--max-clients`).
- 11th client gets a `LobbyFull` control packet back with the current member count. Client surfaces a "Lobby is full (10 / 10)" message; no queue.
- At PCM-stereo (~1.5 Mbps/stream) a 10-peer lobby is ~13.5 Mbps downlink per client. Opus 192 kbps brings that down to ~1.7 Mbps. Both fine on consumer broadband.
### Identity
Each RemSound instance generates a UUID once and persists it in `remsound.config.json` (machine-local, not per-profile, not part of the profile JSON). Survives reboots, profile switches, network changes. Two instances on the same machine have distinct IDs — that's exactly how the "DT + LT both behind same NAT" case stops being special.
### Forwarding rule
- Every packet from a registered client gets forwarded to every other registered client in the same lobby. Unchanged bytes, including the original sender's CLIENT_ID, so receivers know who the audio came from.
- Sender does not need a destination peer list any more. It sends one stream to the server; the server fan-outs.
- Receiver routes incoming packets to the matching `SessionPlayout` keyed by CLIENT_ID (instead of by `(endpoint, streamId)` as today).
### Idle handling
- Per-client idle timeout, 60 s (same as the current relay). Goes silent → expire that client. Doesn't affect anyone else in the lobby.
- Roster broadcast (see §4) fires whenever lobby membership changes, so other clients learn about joins / leaves promptly.
---
## 3. Wire format v2
The relay must distinguish v1 packets (legacy `remsound-relay.py` clients) from v2 (lobby-aware clients) and route accordingly.
### v1 header (existing, unchanged)
```
offset size field
0 4 MAGIC = 'RMND'
4 1 VERSION = 1
5 1 TYPE (Format=1, Audio=2, KeepAlive=3, Heartbeat=4, Control=5)
6 2 STREAM_ID (LE)
8 4 SEQUENCE (LE)
12 payload...
```
Total 12 bytes.
### v2 header (new)
```
offset size field
0 4 MAGIC = 'RMND'
4 1 VERSION = 2
5 1 TYPE (Format=1, Audio=2, KeepAlive=3, Heartbeat=4, Control=5,
LobbyHello=6, LobbyRoster=7, LobbyFull=8, LobbyBye=9)
6 2 STREAM_ID (LE)
8 4 SEQUENCE (LE)
12 16 CLIENT_ID (UUID, RFC 4122 binary form, big-endian by convention)
28 payload...
```
Total 28 bytes. CLIENT_ID slot is the only structural addition.
### Backward compatibility
Server inspects byte 4 (VERSION):
- `0x01` → v1 client. Apply the existing two-slot pairing logic, identical to today. Lets unmodified legacy clients keep working against the new server.
- `0x02` → v2 client. Apply lobby logic.
A single relay instance handles both protocols concurrently. A v1 client and a v2 client cannot share a lobby in this release — that's a deliberate limitation. Practical answer: anyone running a fresh server runs the new bundle, and any client wanting to use a lobby upgrades to a v2-aware build. v1 clients keep working against the same server for pairwise use.
### New packet types (v2 only)
- **LobbyHello (6)** — client → server. Sent immediately after the first audio/format packet, but redundant if those were the first thing seen. Carries the client's display name (UTF-8, max 32 bytes, padded with null). Server uses this for roster announcements.
- **LobbyRoster (7)** — server → client. Sent on every membership change (someone joins, someone leaves, someone updates display name) and periodically at ~1 Hz heartbeat. Carries `count` followed by `count` × `{CLIENT_ID(16), display_name(32 bytes, null-padded UTF-8)}`. Max realistic size: 10 × 48 = 480 bytes + header = well under MTU.
- **LobbyFull (8)** — server → would-be client. Sent in response to a registration attempt when the lobby is at capacity. Carries `current_count(1) max_count(1)`.
- **LobbyBye (9)** — client → server, or server → client. Carries no payload (or just a reason code). Sent on graceful disconnect (client closing) or eviction (server pruning).
These are all small, low-rate, never on the audio hot path.
---
## 4. Server design
### State
```python
# Per-client entry. Identifies who, where, when last seen.
ClientEntry = (
endpoint: (host, port),
display_name: str,
last_seen_monotonic: float,
rx_packets: int,
tx_packets: int,
)
# The entire lobby.
clients: dict[uuid.UUID, ClientEntry] = {}
max_clients: int = 10 # configurable
```
Single flat dict, keyed by CLIENT_ID. No nested rooms.
### Packet flow
For an incoming UDP packet with `(data, addr)`:
1. Parse header. If `MAGIC != 'RMND'`: drop (`rejected_bad_header++`).
2. If `VERSION == 1`: route through legacy two-slot logic (unchanged from today).
3. If `VERSION == 2`:
a. Read `CLIENT_ID` from bytes 12..28.
b. If `CLIENT_ID not in clients`:
- If `len(clients) >= max_clients`: send `LobbyFull` to `addr`, drop the original packet (`dropped_lobby_full++`).
- Else: insert new `ClientEntry(addr, display_name="", monotonic_now, 0, 0)`. Log `event=client_joined`. Schedule a roster broadcast.
c. Existing entry: refresh `endpoint` (handles NAT rebinding) and `last_seen_monotonic`. Bump `rx_packets`.
d. By packet type:
- `LobbyHello`: extract display name, store. Schedule a roster broadcast.
- `LobbyBye`: remove the entry. Log `event=client_left`. Schedule a roster broadcast.
- `Audio` / `Format` / `KeepAlive` / `Heartbeat` / `Control`: forward to every OTHER client in `clients`. Bump each recipient's `tx_packets`.
- Anything else: drop.
### Roster broadcast
- Sent whenever a join / leave / name update happens.
- Also sent periodically (every 1 s) as a heartbeat, so clients quietly detect disconnects when their roster goes empty.
- Built once per cycle, sent unmodified to every connected client.
### Idle expiry
- Once per main loop iteration: walk `clients`, remove any entry whose `last_seen` is older than `IDLE_TIMEOUT_SECONDS` (60). Same timeout as today.
- On removal, log `event=client_idle_expired` and schedule a roster broadcast.
### Logging
Same format as the current relay: structured key=value lines to `/var/log/remsound-relay.log` and stderr. New events:
- `event=client_joined client_id=<uuid> addr=<ip:port> name=<display>`
- `event=client_left client_id=<uuid>`
- `event=client_idle_expired client_id=<uuid>`
- `event=lobby_full attempted_client_id=<uuid> addr=<ip:port>`
- `event=stats forwarded=N dropped_lobby_full=N rejected_bad_header=N client_count=N peers=[...]`
Never logs CLIENT_ID payload bytes beyond the UUID itself. Never logs audio payload.
### Capacity check
```python
def can_admit(client_id: uuid.UUID) -> bool:
return client_id in clients or len(clients) < max_clients
```
That's it. The cap is a soft limit at admit time; existing clients can never get evicted by a newcomer.
### Approximate complexity
For an active lobby of N peers, each audio packet from one client triggers (N - 1) `sock.sendto` calls. At 10 peers and PCM rates (~1500 packets/sec/client → 10 × 1500 = 15,000 inbound/sec → 10 × 9 × 1500 ≈ 135,000 outbound sendto's/sec). Comfortable on any modern Linux. Even a Pi 4 will handle it; a real server is bored.
---
## 5. Client changes (high-level — actual implementation is a separate task)
These are listed so Andre can see the full picture, not because the server bundle has to contain client code.
- `Profile` / `AppConfig`: new `ClientId` field (Guid), persisted in `remsound.config.json`. Generated on first launch.
- `RemPacket`: header reading / writing learns v2 format. Old v1 reads/writes still supported.
- `AudioSender`: sends one stream to the lobby server. Drops the per-peer fan-out loop.
- `AudioReceiver` / `StreamSession`: dictionary key changes from `(IPEndPoint, ushort)` to `(Guid clientId, ushort streamId)`. Endpoint becomes a routing detail.
- `HeartbeatService`: peer-health tracking keyed by `ClientId`.
- `Connectivity` tab: "Lobby" section showing connected lobby members from the latest `LobbyRoster`. Replaces the per-pair "Discovered peers" model for lobby connections. LAN discovery still works for non-lobby pair sessions.
- `Selected peers` semantics unchanged: still a tick-to-accept allow-list, but keyed on CLIENT_ID rather than IP.
A migration phase where v1 packets are still emitted lets older clients keep talking to the new server for pair use.
---
## 6. Auto-update on the server
### Goal
Push a new server release to GitHub → every relay running this bundle picks it up within an hour and restarts itself onto the new version. Andre never has to re-SCP, never has to remember to update.
### Mechanism
Three new pieces ship in the bundle alongside `remsound-relay.py`:
1. **`remsound-relay-update.sh`** — the updater script. Bash. Talks to GitHub.
2. **`remsound-relay-update.service`** — systemd unit that runs the updater once.
3. **`remsound-relay-update.timer`** — systemd timer firing the updater on a schedule.
The relay binary itself doesn't reach out. Separation of concerns: the relay just relays; the updater just updates. If the updater is broken, the relay keeps running. If the relay crashes, the updater keeps trying to install fixes.
### Updater script behaviour
```
remsound-relay-update.sh:
1. Read current version from /etc/remsound-relay/version (file contains a single line like "server-v2.0").
If the file is missing, treat the installed version as "server-v0".
2. Call GitHub API: https://api.github.com/repos/Ednunp/RemSound/releases
Filter releases whose tag starts with "server-". Take the highest by tag semver.
3. If latest <= current: log "up to date" and exit 0.
4. If latest > current:
a. Look in the release's assets for `remsound-server-<tag>.tar.gz`. If missing, log warning, exit 1.
b. curl the asset to /tmp/remsound-server-<tag>.tar.gz.
c. (Optional v2) Verify SHA256 against a second asset `remsound-server-<tag>.tar.gz.sha256`.
d. Extract to /tmp/remsound-server-<tag>/.
e. Stop the relay: systemctl stop remsound-relay.
f. Copy the new files into place (replacing /usr/local/sbin/remsound-relay.py and
/etc/systemd/system/remsound-relay.service if present in the asset).
g. systemctl daemon-reload, systemctl start remsound-relay.
h. Wait 3 seconds. Check the service is active. If yes: write the new tag to
/etc/remsound-relay/version, log success, clean up /tmp staging.
If no: roll back from a backup of the old files (kept at /etc/remsound-relay/backup/),
restart the service, log failure. Exit 1.
5. Log to /var/log/remsound-relay-update.log.
```
The updater self-update case (a new updater script is itself shipped in a release) is handled by the same copy step in 4f — the running updater finishes its current cycle, exits, and next cycle the new updater script is what runs.
### Systemd timer
```
# remsound-relay-update.timer
[Unit]
Description=Periodic update check for remsound-relay
[Timer]
OnBootSec=2min
OnUnitActiveSec=1h
RandomizedDelaySec=10min
Persistent=true
[Install]
WantedBy=timers.target
```
- Fires 2 minutes after boot, then every hour thereafter.
- `RandomizedDelaySec=10min` so multiple relays don't all hit the GitHub API at the same exact instant if you ever run many.
- `Persistent=true` ensures missed runs (host was off) catch up on next boot.
### Systemd service for the updater
```
# remsound-relay-update.service
[Unit]
Description=Check for and apply remsound-relay updates from GitHub
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/remsound-relay-update.sh
Restart=no
```
One-shot. Triggered only by the timer. Failure is logged but not retried — the next hourly tick has another go.
### Release tag convention
Server releases use the tag pattern `server-vMAJOR.MINOR` (e.g. `server-v2.0`, `server-v2.1`). Client releases keep their existing `vMAJOR.MINOR` pattern (`v1.4`, `v1.5`). The updater filter (`tag startswith "server-"`) makes the two streams orthogonal: client releases don't trigger relay updates, and vice versa.
A release asset name is `remsound-server-<tag>.tar.gz`. Tarball contents:
```
remsound-server-v2.0/
├── remsound-relay.py
├── remsound-relay.service
├── remsound-relay-update.sh
├── remsound-relay-update.service
├── remsound-relay-update.timer
├── install.sh
├── uninstall.sh
├── smoke-test.sh
├── VERSION # contains "server-v2.0\n"
└── README.md
```
### Rollback path
On failed update (service won't come up after install), the updater restores from `/etc/remsound-relay/backup/`, which `install.sh` populates at install time (and which the updater itself refreshes before every replacement). If even that fails, manual recovery is the same as today: SSH in, `apt install python3` (already there), and copy the bundle from a working host or `git clone` the repo.
### Disabling auto-update
Anyone who wants the relay to stay pinned at a known version simply disables the timer:
```
sudo systemctl disable --now remsound-relay-update.timer
```
Service keeps running. No further updates. Re-enabling resumes the schedule.
---
## 7. Installation on a fresh host
### Raspberry Pi (or any Debian/Ubuntu/Pi OS)
1. Download the latest server tarball from the GitHub release page (or have the updater do it after manual bootstrap).
2. Extract, `cd` into the folder, `sudo ./install.sh`.
3. Open UDP 47830 in the host's firewall and / or router.
### Full Linux server (Andre's box)
Same script. The differences are operational, not structural:
- Andre will likely want a non-default port (e.g. 47830 is fine for one lobby; second instance on 47831 if needed). Add `--port` flag to `remsound-relay.service` `ExecStart=` line.
- He may want the log file under `/var/log/journal/` and a smaller log retention; that's a `journald.conf` concern, not the relay's.
- `max-clients` configurable via env var `REMSOUND_MAX_CLIENTS` so it can be set in the systemd unit without editing the script.
### What `install.sh` does in the new bundle
Adds three things to the existing five steps:
6. Install `remsound-relay-update.sh` to `/usr/local/sbin/`.
7. Install `remsound-relay-update.service` and `remsound-relay-update.timer` to `/etc/systemd/system/`.
8. `systemctl enable --now remsound-relay-update.timer`.
9. Write the current bundle's version to `/etc/remsound-relay/version`.
10. Snapshot current files to `/etc/remsound-relay/backup/` for the updater's rollback path.
`uninstall.sh` gets the matching teardown.
### Smoke test
`smoke-test.sh` checks:
- Service is active.
- UDP 47830 has a listener.
- Log file is being written to.
- (New) Updater service exists and the timer is active.
- (New) `/etc/remsound-relay/version` is readable and matches expected format.
---
## 8. GitHub release process
When we cut a new server release:
1. Bump version in `VERSION` and in `remsound-relay.py`'s startup-log line.
2. Tag: `git tag server-v2.1 && git push origin server-v2.1`.
3. `tar czf remsound-server-v2.1.tar.gz remsound-server-v2.1/` (where the folder is a staging area with the bundle contents).
4. `gh release create server-v2.1 remsound-server-v2.1.tar.gz --title "server v2.1" --notes "…"`.
5. Within an hour, every running relay's updater catches the release, downloads it, restarts the service.
We can additionally publish a `.sha256` alongside if we want signature-style integrity checks; not strictly required because GitHub serves the tarball over HTTPS.
### Release notes content
Keep them short and operational: what changed, what the operator needs to know, anything they need to do manually (usually nothing — that's the whole point of auto-update).
---
## 9. Migration phases
Ordered so each step is independently testable and rollback-able. Server work is steps 13; client work is 46.
1. **v2 wire format added to client side, still emits v1 by default.** No behaviour change. Just makes the new header reading / writing code paths exist. Backward-compatible with existing relays.
2. **New `remsound-lobby.py` ships as `server-v2.0`.** Handles v1 packets exactly like today's relay (two-slot pairing); handles v2 packets via the lobby logic. Initial deployment: install on onj.me alongside / replacing the current relay. Existing v1 clients keep working pairwise.
3. **Auto-updater bundle goes live.** Once steps 1 and 2 are in place, future server changes roll out without manual deployment.
4. **Client adds CLIENT_ID generation + persistence.** New UUID stamped on every outbound v2 packet. Server now sees the same client by ID even if endpoint changes.
5. **Client receiver re-keys sessions on CLIENT_ID.** Internal change. Lobby connections become a real first-class thing in the UI.
6. **Client UI: lobby tab / lobby roster integration.** Replace per-pair manual peer entry with a "connect to lobby" affordance. LAN discovery still works in parallel for non-lobby setups.
Each phase is a separate commit (and release where appropriate). Steps 13 are server-side and can ship without any RemSound client release. Steps 46 are client-side; they should ship in a single client release (probably `v1.5` or `v2.0` depending on how disruptive the wire change feels).
---
## 10. Testing
### Server-side
- **`smoke-test.sh`** runs the basic install-check (listener bound, service active, log present, updater timer enabled).
- **2-client v1 pair test**: existing test from the current bundle. Should still pass — proves backward compat.
- **3-client v2 lobby test**: bring up three RemSound instances (or three test scripts that mimic v2 packets), all dial the relay, confirm each receives the other two's audio.
- **Lobby-full test**: 11th client receives `LobbyFull`. Server doesn't crash.
- **Idle eviction test**: stop one client, wait 60 s, confirm the other clients receive an updated roster.
- **Auto-update dry-run**: tag a no-op server release, watch the timer fire, confirm the relay picks it up, restarts, and the new version shows in `/etc/remsound-relay/version`.
### Client-side (once steps 46 land)
- Two instances behind the same NAT both connect to the same lobby: each sees the other and the remote peer in the lobby roster.
- Network rebinding (Wi-Fi → Ethernet): client session stays alive; endpoint update is picked up by the server.
- Lobby-full: clean UI message.
- Mixed lobby of v2 clients only (initially we won't support mixing v1 and v2 in one lobby — it's a separate work stream if ever needed).
### Production canary
When `server-v2.0` is ready, ship it first to one of the relays (the Pi or onj.me, your call), watch logs for a day or two, then deploy to the other. Auto-update + atomic rollback means even a bad release doesn't take both hosts offline simultaneously.
---
## 11. Open questions
These should be answered before code starts but aren't blocking for design review.
1. **Display names**: do we want them at all in v2.0, or defer to v2.1? They're nice for UI roster display but not load-bearing for routing. **Suggestion: defer.** Client sends `LobbyHello` with display name = profile name; server treats missing / empty display names as "Unknown" in the roster.
2. **Authentication**: do we need a shared secret to join a lobby on onj.me? **Suggestion: not for v2.0.** Anyone who knows the address can connect. If unwanted-strangers becomes a problem, add a `LobbyAuth(secret)` packet type in a later release.
3. **IPv6**: the existing relay binds IPv4 only. For Andre's full server it'd be reasonable to bind both. **Suggestion: optional dual-stack via `--bind6`. Default IPv4 to match existing behaviour.**
4. **Metrics endpoint**: would a Prometheus-style `/metrics` HTTP endpoint be useful for Andre? **Suggestion: not in v2.0. The per-minute stats line in the log file is enough until someone asks.**
5. **Recording at the server**: a server-side recording feature would be powerful (capture all lobby audio for later playback / archive) but adds the kind of complexity the rest of this design carefully avoids. **Suggestion: defer indefinitely.**
6. **Renegotiating CLIENT_ID**: if someone wants to wipe their identity (privacy / fresh start), the simplest answer is "delete the line from `remsound.config.json` and restart". No protocol-level renegotiation needed.
---
## 12. Quick reference for Andre
If you're reading this to set up your Linux box once the v2.0 release is out:
```
# 1. Download and extract the latest server bundle.
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. This sets up the relay AND the auto-updater.
sudo ./install.sh
# 3. Open UDP 47830 in the firewall.
sudo ufw allow 47830/udp
# (or whatever your firewall is)
# 4. Confirm it's alive.
sudo ./smoke-test.sh
```
After that you can forget about it. Future updates roll out automatically. If you want to pin a version, `sudo systemctl disable --now remsound-relay-update.timer`. If you want to remove it entirely, `sudo ./uninstall.sh`.
Log file is `/var/log/remsound-relay.log` for the relay itself, `/var/log/remsound-relay-update.log` for the update history. Both rotate via the system's logrotate defaults.
---
## 13. Status
This is a design + handover document, not the implementation. None of the v2 code exists yet. When ready to build:
- `remsound-lobby.py` (~250 lines Python) — the new lobby relay.
- `remsound-relay-update.sh` (~150 lines Bash) — the updater.
- Two systemd units (~30 lines total) — service + timer for the updater.
- `install.sh` / `uninstall.sh` / `smoke-test.sh` updates.
- Wire format v2 in `RemSound.Core.RemPacket` (~100 lines C# delta).
- Per-client UUID persistence + heartbeat / session refactor in the client (~few hundred lines).
- Client UI for the lobby tab (later phase, optional in v2.0 release).
Total scope: a couple of focused days for the server bundle, several days for the client refactor + UI. Server-side ships first and runs alongside the existing two-slot relay without breaking it.
+13
View File
@@ -0,0 +1,13 @@
[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
@@ -0,0 +1,358 @@
#!/bin/bash
# remsound-relay-update.sh — periodic GitHub-release-based updater for the
# RemSound relay. Designed to run from a systemd .timer; safe to run by hand
# too. Idempotent: if there is no newer release, exits 0 quickly with no
# system changes.
#
# What it does:
# 1. Read the installed tag from /etc/remsound-relay/version.
# 2. Query the GitHub Releases API for tags starting with "server-".
# 3. If the latest is newer than the installed tag:
# a. Download the matching tarball asset.
# b. Snapshot current installed files to /etc/remsound-relay/backup/.
# c. Stop the relay service.
# d. Replace the relay files with the new tarball contents.
# e. systemctl daemon-reload + start.
# f. Wait 3s and check the service is active.
# If yes: write the new tag, log success, exit 0.
# If no: restore from backup, restart, log failure, exit 1.
# 4. Log everything to /var/log/remsound-relay-update.log.
#
# Failure modes are defensive — a broken updater run leaves the previously
# installed version running, never less.
set -euo pipefail
# -------- configuration ------------------------------------------------------
REPO="${REMSOUND_UPDATE_REPO:-Ednunp/RemSound}"
TAG_PREFIX="${REMSOUND_UPDATE_TAG_PREFIX:-server-}"
ASSET_PATTERN="${REMSOUND_UPDATE_ASSET_PATTERN:-remsound-server-.*\.tar\.gz$}"
INSTALL_BIN="/usr/local/sbin/remsound-relay.py"
INSTALL_UPDATER="/usr/local/sbin/remsound-relay-update.sh"
INSTALL_SERVICE="/etc/systemd/system/remsound-relay.service"
INSTALL_UPDATE_SERVICE="/etc/systemd/system/remsound-relay-update.service"
INSTALL_UPDATE_TIMER="/etc/systemd/system/remsound-relay-update.timer"
STATE_DIR="/etc/remsound-relay"
VERSION_FILE="$STATE_DIR/version"
BACKUP_DIR="$STATE_DIR/backup"
LOG_FILE="/var/log/remsound-relay-update.log"
SERVICE_NAME="remsound-relay.service"
HEALTH_WAIT_SECONDS=3
# Script-global mktemp working directory. Set by main(); cleaned up by the
# EXIT trap below. Kept at script scope (not function-local) so the trap can
# still see it under `set -u` after main returns.
WORK_DIR=""
cleanup_work_dir() {
if [[ -n "$WORK_DIR" && -d "$WORK_DIR" ]]; then
rm -rf "$WORK_DIR"
fi
}
trap cleanup_work_dir EXIT
# -------- helpers ------------------------------------------------------------
log() {
local ts msg
ts="$(date '+%Y-%m-%d %H:%M:%S')"
msg="$ts $*"
printf '%s\n' "$msg" | tee -a "$LOG_FILE" >&2
}
require_root() {
if [[ $EUID -ne 0 ]]; then
log "ERROR: this script must run as root"
exit 1
fi
}
ensure_dirs() {
mkdir -p "$STATE_DIR" "$BACKUP_DIR" "$(dirname "$LOG_FILE")"
touch "$LOG_FILE"
}
read_current_version() {
if [[ -f "$VERSION_FILE" ]]; then
local v
v="$(tr -d '[:space:]' < "$VERSION_FILE")"
if [[ -n "$v" ]]; then
printf '%s' "$v"
return
fi
fi
printf '%s' "${TAG_PREFIX}v0"
}
# Parse a tag like "server-v2.10" into a comparable numeric form.
# Outputs MAJOR.MINOR; both default to 0 if the tag is unparseable.
tag_to_version() {
local tag="$1"
# strip the prefix
tag="${tag#"$TAG_PREFIX"}"
# strip a leading "v" if present
tag="${tag#v}"
local major minor
major="${tag%%.*}"
minor="${tag#*.}"
# if there's no dot, minor==major. Treat as MAJOR.0.
if [[ "$minor" == "$tag" ]]; then
minor="0"
fi
# keep only digits — survive things like "v2.0-rc1" by ignoring the suffix.
major="${major//[^0-9]/}"
minor="${minor//[^0-9]/}"
: "${major:=0}"
: "${minor:=0}"
printf '%s.%s' "$major" "$minor"
}
# Returns 0 if $1 > $2 (i.e. left tag is newer), 1 otherwise.
tag_newer_than() {
local left right lv rv
left="$(tag_to_version "$1")"
right="$(tag_to_version "$2")"
# Numeric compare major then minor.
lv="${left%.*}"; rv="${right%.*}"
if (( lv > rv )); then return 0; fi
if (( lv < rv )); then return 1; fi
lv="${left#*.}"; rv="${right#*.}"
if (( lv > rv )); then return 0; fi
return 1
}
# -------- GitHub releases query ---------------------------------------------
# Fetch the releases list and pick the latest server-tag release.
# Outputs three lines: tag, asset_url, asset_name. Exits non-zero on no match.
get_latest_release() {
local json
if ! json="$(curl --fail --silent --show-error --max-time 30 \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${REPO}/releases?per_page=30" 2>&1)"; then
log "ERROR: failed to query GitHub releases: $json"
return 2
fi
REPO="$REPO" TAG_PREFIX="$TAG_PREFIX" ASSET_PATTERN="$ASSET_PATTERN" \
python3 - "$json" <<'PY'
import json, os, re, sys
raw = sys.argv[1] if len(sys.argv) > 1 else ""
prefix = os.environ.get("TAG_PREFIX", "server-")
asset_re = re.compile(os.environ.get("ASSET_PATTERN", r"remsound-server-.*\.tar\.gz$"))
try:
releases = json.loads(raw)
except Exception as exc:
sys.stderr.write(f"json parse failed: {exc}\n")
sys.exit(3)
if not isinstance(releases, list):
sys.stderr.write(f"unexpected releases response shape\n")
sys.exit(3)
def parse_version(tag):
# strip prefix
if tag.startswith(prefix):
tag = tag[len(prefix):]
if tag.startswith("v"):
tag = tag[1:]
parts = tag.split(".")
out = []
for p in parts:
digits = "".join(c for c in p if c.isdigit())
out.append(int(digits) if digits else 0)
if len(out) < 2:
out.append(0)
return tuple(out)
candidates = []
for r in releases:
if not isinstance(r, dict):
continue
tag = r.get("tag_name") or ""
if not tag.startswith(prefix):
continue
if r.get("draft") or r.get("prerelease"):
continue
asset = None
for a in r.get("assets") or []:
name = (a or {}).get("name") or ""
if asset_re.search(name):
asset = a
break
if asset is None:
continue
url = asset.get("browser_download_url") or ""
name = asset.get("name") or ""
if not url:
continue
candidates.append((parse_version(tag), tag, url, name))
if not candidates:
sys.stderr.write("no eligible server-* releases found\n")
sys.exit(4)
candidates.sort(reverse=True)
_, tag, url, name = candidates[0]
print(tag)
print(url)
print(name)
PY
}
# -------- backup + install ---------------------------------------------------
snapshot_backup() {
log "snapshotting current install to $BACKUP_DIR"
rm -rf "$BACKUP_DIR"
mkdir -p "$BACKUP_DIR"
for f in \
"$INSTALL_BIN" "$INSTALL_UPDATER" \
"$INSTALL_SERVICE" "$INSTALL_UPDATE_SERVICE" "$INSTALL_UPDATE_TIMER" \
"$VERSION_FILE"
do
if [[ -f "$f" ]]; then
cp -a "$f" "$BACKUP_DIR/$(basename "$f")"
fi
done
}
restore_backup() {
log "rolling back from $BACKUP_DIR"
for f in \
"$INSTALL_BIN" "$INSTALL_UPDATER" \
"$INSTALL_SERVICE" "$INSTALL_UPDATE_SERVICE" "$INSTALL_UPDATE_TIMER" \
"$VERSION_FILE"
do
local backup="$BACKUP_DIR/$(basename "$f")"
if [[ -f "$backup" ]]; then
cp -a "$backup" "$f"
fi
done
systemctl daemon-reload
systemctl start "$SERVICE_NAME" || true
}
install_from_staging() {
local staging="$1"
# Required files: remsound-relay.py + remsound-relay.service.
if [[ ! -f "$staging/remsound-relay.py" ]]; then
log "ERROR: staging missing remsound-relay.py"
return 1
fi
install -o root -g root -m 755 "$staging/remsound-relay.py" "$INSTALL_BIN"
python3 -m py_compile "$INSTALL_BIN"
if [[ -f "$staging/remsound-relay.service" ]]; then
install -o root -g root -m 644 "$staging/remsound-relay.service" "$INSTALL_SERVICE"
fi
if [[ -f "$staging/remsound-relay-update.sh" ]]; then
install -o root -g root -m 755 "$staging/remsound-relay-update.sh" "$INSTALL_UPDATER"
fi
if [[ -f "$staging/remsound-relay-update.service" ]]; then
install -o root -g root -m 644 "$staging/remsound-relay-update.service" "$INSTALL_UPDATE_SERVICE"
fi
if [[ -f "$staging/remsound-relay-update.timer" ]]; then
install -o root -g root -m 644 "$staging/remsound-relay-update.timer" "$INSTALL_UPDATE_TIMER"
fi
return 0
}
# -------- main flow ----------------------------------------------------------
main() {
require_root
ensure_dirs
log "update check starting (repo=$REPO prefix=$TAG_PREFIX)"
local current latest_tag asset_url asset_name
current="$(read_current_version)"
log "currently installed: $current"
local release_info
if ! release_info="$(get_latest_release)"; then
log "no upgrade attempted (could not query releases or no eligible release)"
return 0
fi
latest_tag="$(printf '%s\n' "$release_info" | sed -n '1p')"
asset_url="$(printf '%s\n' "$release_info" | sed -n '2p')"
asset_name="$(printf '%s\n' "$release_info" | sed -n '3p')"
log "latest available: $latest_tag asset=$asset_name"
if ! tag_newer_than "$latest_tag" "$current"; then
log "up to date (installed $current >= available $latest_tag)"
return 0
fi
log "newer release found: $latest_tag -> upgrading from $current"
# Working area in /tmp. Use the script-global $WORK_DIR (not a function
# local) so the EXIT trap can still see the variable after main returns.
# The trap is also script-global, registered just below.
WORK_DIR="$(mktemp -d -t remsound-relay-update.XXXXXXXX)"
local tarball="$WORK_DIR/$asset_name"
log "downloading $asset_url"
if ! curl --fail --silent --show-error --max-time 120 \
--location -o "$tarball" "$asset_url"; then
log "ERROR: download failed"
return 1
fi
log "extracting $asset_name"
if ! tar -xzf "$tarball" -C "$WORK_DIR"; then
log "ERROR: tarball extraction failed"
return 1
fi
# Find the staging root — first directory inside the work dir.
local staging
staging="$(find "$WORK_DIR" -mindepth 1 -maxdepth 1 -type d | head -n 1)"
if [[ -z "$staging" ]]; then
log "ERROR: tarball did not contain a top-level folder"
return 1
fi
log "staging at $staging"
snapshot_backup
log "stopping $SERVICE_NAME"
systemctl stop "$SERVICE_NAME" || true
if ! install_from_staging "$staging"; then
log "ERROR: install step failed — rolling back"
restore_backup
return 1
fi
# Persist the new version BEFORE starting, so a crash after start still
# leaves the version file consistent with what's on disk.
printf '%s\n' "$latest_tag" > "$VERSION_FILE"
log "reloading systemd + starting $SERVICE_NAME"
systemctl daemon-reload
systemctl start "$SERVICE_NAME" || true
sleep "$HEALTH_WAIT_SECONDS"
if systemctl is-active --quiet "$SERVICE_NAME"; then
log "post-install check: $SERVICE_NAME is active — upgrade to $latest_tag complete"
return 0
fi
log "post-install check FAILED: $SERVICE_NAME not active — rolling back"
restore_backup
if systemctl is-active --quiet "$SERVICE_NAME"; then
log "rollback succeeded — back on $current"
else
log "ERROR: rollback also did not restore service — manual intervention required"
fi
return 1
}
main "$@"
+16
View File
@@ -0,0 +1,16 @@
[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
@@ -0,0 +1,547 @@
#!/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
@@ -0,0 +1,14 @@
[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
@@ -0,0 +1,123 @@
#!/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
@@ -0,0 +1,63 @@
#!/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."