Files
RemSound/server/remsound server update.md
T

24 KiB
Raw Blame History

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

# 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

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:

  1. Install remsound-relay-update.sh to /usr/local/sbin/.
  2. Install remsound-relay-update.service and remsound-relay-update.timer to /etc/systemd/system/.
  3. systemctl enable --now remsound-relay-update.timer.
  4. Write the current bundle's version to /etc/remsound-relay/version.
  5. 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.