# 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= addr= name=` - `event=client_left client_id=` - `event=client_idle_expired client_id=` - `event=lobby_full attempted_client_id= addr=` - `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-.tar.gz`. If missing, log warning, exit 1. b. curl the asset to /tmp/remsound-server-.tar.gz. c. (Optional v2) Verify SHA256 against a second asset `remsound-server-.tar.gz.sha256`. d. Extract to /tmp/remsound-server-/. 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-.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 1–3; client work is 4–6. 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 1–3 are server-side and can ship without any RemSound client release. Steps 4–6 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 4–6 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.