Add Pi relay server bundle (server/)
This commit is contained in:
@@ -47,8 +47,15 @@ src/RemSound.Sender capture → mix → encode → UDP send
|
||||
src/RemSound.Receiver UDP receive → ring buffer → drift-corrected playout → render
|
||||
src/RemSound.Harness console test program (1 sender → 1 receiver, no UI)
|
||||
src/RemSound.App WinForms UI (sender + receiver + heartbeat + discovery + updater)
|
||||
server/ optional Raspberry Pi / systemd-Linux relay bundle (see below)
|
||||
```
|
||||
|
||||
## Optional: running your own relay server
|
||||
|
||||
Two RemSound peers normally reach each other directly over your LAN, or via Tailscale across the internet. If neither of those work for your situation — for example one peer is behind a router that won't forward inbound UDP and you'd prefer not to use Tailscale — you can run a small Python relay on a publicly-reachable host (a Raspberry Pi at home with one UDP port forwarded works fine) and have both peers dial that.
|
||||
|
||||
The `server/` folder in this repo is a self-contained bundle: relay script, systemd unit, install / uninstall / smoke-test scripts, and a step-by-step README. See [`server/README.md`](server/README.md) for the setup walkthrough.
|
||||
|
||||
## Issues and feedback
|
||||
|
||||
Open an issue on the [GitHub issues page](https://github.com/Ednunp/RemSound/issues). If reporting an audio problem, please tick **File → Preferences → Enable logs**, reproduce the issue, then attach the latest log file from `logs\` next to `RemSound.exe`.
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
# RemSound UDP Relay — server bundle
|
||||
|
||||
This folder is a self-contained bundle for running the RemSound UDP relay on a Raspberry Pi (or any systemd-based Linux). Drop it on the Pi, run `install.sh`, open one router port, and you have a relay another RemSound peer can dial.
|
||||
|
||||
## Why this exists
|
||||
|
||||
RemSound is a Windows audio app that moves real-time audio between two PCs over plain UDP. The standard way two RemSound peers reach each other when one of them is behind a router that won't forward inbound UDP is to run Tailscale on both ends — Tailscale's WireGuard tunnel handles the NAT traversal. That works, but it adds encryption overhead and sometimes routes traffic through Tailscale's DERP relay rather than directly.
|
||||
|
||||
A relay is a tiny UDP reflector that sits on a publicly-reachable host. Both peers dial it, the relay learns each peer's apparent address from their first packet, and forwards datagrams between matched peers. RemSound peers stay behind their NATs — the relay is the only thing that needs to be reachable from the public internet.
|
||||
|
||||
Whether a relay improves perceived latency depends on geography. Running a relay close to one of the peers can give a measurable improvement over Tailscale's DERP fallback; running a relay far from both peers usually makes things worse than direct Tailscale. The relay is most useful when at least one peer is behind a router that won't let inbound UDP through at all (so direct peer-to-peer is impossible) and Tailscale is undesirable for some reason (encryption overhead, account requirement, mobile data plan).
|
||||
|
||||
## What's in this bundle
|
||||
|
||||
```
|
||||
remsound-relay.py — the relay service itself (Python 3, stdlib only)
|
||||
remsound-relay.service — systemd unit
|
||||
install.sh — one-shot installer (copy + enable + start)
|
||||
uninstall.sh — clean uninstaller
|
||||
smoke-test.sh — health check after install
|
||||
README.md — this file
|
||||
```
|
||||
|
||||
No external dependencies. The script uses only the Python 3 standard library.
|
||||
|
||||
## What it does
|
||||
|
||||
- Listens on UDP port **47830**.
|
||||
- Validates the 12-byte RemSound packet header (magic `RMND`, version 1) and silently drops anything else. **Never decodes audio. Never logs payload bytes.**
|
||||
- Holds two peer slots. The first two distinct UDP endpoints to send valid RemSound packets claim the slots. Once both slots are filled, every valid packet from one slot is forwarded to the other.
|
||||
- Slots that go silent for more than 60 seconds are replaced when a fresh endpoint arrives — so a stale pairing self-heals when one side restarts RemSound.
|
||||
- Logs to `/var/log/remsound-relay.log` — startup, peer-joined / peer-paired / peer-dropped events, per-minute throughput stats. No payload bytes, ever.
|
||||
|
||||
## Install
|
||||
|
||||
1. Copy this whole folder onto the Pi. SSH or USB stick are both fine. Example over SSH from your laptop, replacing `pi@your-pi-host` with your actual Pi user@host:
|
||||
|
||||
```
|
||||
scp -r server pi@your-pi-host:/home/pi/remsound-relay
|
||||
```
|
||||
|
||||
2. SSH into the Pi:
|
||||
|
||||
```
|
||||
ssh pi@your-pi-host
|
||||
```
|
||||
|
||||
3. Run the installer:
|
||||
|
||||
```
|
||||
cd /home/pi/remsound-relay
|
||||
sudo ./install.sh
|
||||
```
|
||||
|
||||
It copies the script to `/usr/local/sbin/`, installs the systemd unit, enables and starts the service, and prints the status + last few log lines.
|
||||
|
||||
4. Optional: run the smoke test to confirm everything works locally:
|
||||
|
||||
```
|
||||
sudo ./smoke-test.sh
|
||||
```
|
||||
|
||||
You should see `[ok] remsound-relay.service is active`, `[ok] listening on UDP 47830`, and `[ok] relay logged a peer_joined event for 127.0.0.1 — header validation works`.
|
||||
|
||||
## Open one router port
|
||||
|
||||
Without this step the relay only works on your LAN. To make it reachable from the internet:
|
||||
|
||||
1. Find the Pi's LAN IP (`hostname -I` on the Pi, take the first IPv4 address).
|
||||
2. In your router's admin UI, add a port-forward rule:
|
||||
- **Protocol:** UDP
|
||||
- **WAN port:** 47830
|
||||
- **Forward IP:** the Pi's LAN IP (from step 1)
|
||||
- **Forward port:** 47830
|
||||
3. Save / apply.
|
||||
|
||||
That's the same kind of rule any other "let an outside service reach a device on my LAN" flow needs. Different routers have it under different menus (Settings → NAT, Settings → Port Forwarding, Advanced → Virtual Server, etc.). The protocol must be **UDP**, not TCP.
|
||||
|
||||
If your router doesn't allow inbound port-forwards at all, this relay can't be reached from outside your LAN. In that case stay on Tailscale.
|
||||
|
||||
## Tell the other peer the address
|
||||
|
||||
The other end of RemSound needs to dial **your-public-host:47830**.
|
||||
|
||||
If you have a static public IP, your peer types it directly:
|
||||
|
||||
```
|
||||
123.45.67.89:47830
|
||||
```
|
||||
|
||||
If your IP is dynamic (most home connections are), you'll want a Dynamic-DNS hostname. Free options that work well with consumer routers:
|
||||
|
||||
- **Namecheap Dynamic DNS** (if you own a domain there) — set up a host record and run their updater on the Pi or in your router.
|
||||
- **DuckDNS** (free, no domain needed) — your hostname looks like `something.duckdns.org`.
|
||||
- **No-IP**, **DynDNS**, etc.
|
||||
|
||||
Whichever you pick, the result is a hostname like `mypi.example.com` that always points at your current public IP. The other peer dials `mypi.example.com:47830` and it just works.
|
||||
|
||||
## Use it from RemSound
|
||||
|
||||
In RemSound's "Add peer by IP or hostname" field, both ends type **just the hostname** — no port suffix needed:
|
||||
|
||||
```
|
||||
your-public-host
|
||||
```
|
||||
|
||||
RemSound defaults to port **47830** (the relay convention) when you don't type a `:port` suffix. Both ends must type the same address so they meet at the same relay.
|
||||
|
||||
If you do want LAN peer-to-peer (no relay), type the host with an explicit port:
|
||||
|
||||
```
|
||||
192.168.1.42:47820
|
||||
```
|
||||
|
||||
The build auto-detects relay-vs-LAN from the port: anything ≠ 47820 is treated as a relay endpoint and heartbeat shares the audio sender's UDP socket so both flows traverse the same NAT pinhole. No UI toggle needed.
|
||||
|
||||
## Verify it's working
|
||||
|
||||
Once both ends have dialled the relay, on the Pi you'll see in `/var/log/remsound-relay.log`:
|
||||
|
||||
```
|
||||
event=peer_joined addr=A.B.C.D:NNNN slots_filled=1
|
||||
event=peer_joined addr=W.X.Y.Z:NNNN slots_filled=2
|
||||
event=peer_paired a=A.B.C.D:NNNN b=W.X.Y.Z:NNNN
|
||||
event=stats forwarded=NNNNN dropped_unpaired=0 rejected_bad_header=0 ...
|
||||
```
|
||||
|
||||
A live tail from the Pi:
|
||||
|
||||
```
|
||||
sudo tail -f /var/log/remsound-relay.log
|
||||
```
|
||||
|
||||
Stats lines come once a minute. `forwarded=N` is the count of packets the relay reflected between peers in that minute. RemSound at 10 ms Opus generates ~100 packets/sec per direction = ~12,000/minute total. PCM with Tight ASIO can be 10× that.
|
||||
|
||||
## Operational quick-reference
|
||||
|
||||
```
|
||||
# Service control
|
||||
sudo systemctl status remsound-relay
|
||||
sudo systemctl restart remsound-relay
|
||||
sudo systemctl stop remsound-relay
|
||||
sudo systemctl start remsound-relay
|
||||
|
||||
# Logs
|
||||
sudo tail -f /var/log/remsound-relay.log
|
||||
sudo journalctl -u remsound-relay --since "30 minutes ago" --no-pager
|
||||
|
||||
# Listening socket
|
||||
sudo ss -lunp | grep 47830
|
||||
|
||||
# Health check (re-runnable any time)
|
||||
sudo /home/pi/remsound-relay/smoke-test.sh
|
||||
```
|
||||
|
||||
## Update / re-install
|
||||
|
||||
To pick up a newer copy of the bundle, `git pull` (or re-download) the RemSound repo, copy the latest `server/` folder to the Pi, and run `sudo ./install.sh` again. It overwrites the script and restarts the service. No state to migrate.
|
||||
|
||||
## Uninstall
|
||||
|
||||
```
|
||||
sudo ./uninstall.sh
|
||||
```
|
||||
|
||||
Stops the service, disables it, removes the script and systemd unit. Leaves the log file alone — delete `/var/log/remsound-relay.log` yourself if you don't want it kept. Doesn't touch your router port-forward.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Service won't start.** `sudo systemctl status remsound-relay` will show an error. The most likely cause is something else already bound to UDP 47830 — `sudo ss -lunp | grep 47830` will show what. Pick a different port: edit `DEFAULT_PORT` in `/usr/local/sbin/remsound-relay.py`, restart with `sudo systemctl restart remsound-relay`, and tell the other peer the new port number.
|
||||
|
||||
**Relay running but the other peer can't connect.** Almost always the router port-forward. Check:
|
||||
|
||||
- The rule is **UDP** not TCP.
|
||||
- The internal IP matches the Pi's actual LAN IP (run `hostname -I` to confirm).
|
||||
- WAN port and forward port are both 47830.
|
||||
- Some routers need a reboot after a new rule. Try one if nothing else helps.
|
||||
|
||||
To prove the port is open from outside, on a non-LAN machine (a phone on mobile data works) send a test packet:
|
||||
|
||||
```
|
||||
python3 -c "import socket; s=socket.socket(socket.AF_INET, socket.SOCK_DGRAM); s.sendto(bytes([0x52,0x4D,0x4E,0x44,1,4,1,0,1,0,0,0]), ('your-public-host', 47830))"
|
||||
```
|
||||
|
||||
Then on the Pi:
|
||||
|
||||
```
|
||||
sudo tail -10 /var/log/remsound-relay.log
|
||||
```
|
||||
|
||||
You should see `event=peer_joined` with the source address.
|
||||
|
||||
**Audio plays but is laggy / clicky.** That's not the relay's problem. The relay forwards packets blind — anything fancy belongs at the RemSound endpoints. Check the RemSound diagnostics: codec choice (PCM vs Opus, Opus 10 ms is the lowest-latency Opus), buffer smoothness, and either side's upload bandwidth. PCM with Tight ASIO is bandwidth-heavy (often 10+ Mbps); Opus 10 ms is around 200 kbps.
|
||||
|
||||
**Asymmetric packet rate in the stats line.** Like `peers=[X(rx=90000,tx=12000), Y(rx=12000,tx=90000)]`. That just means one side is sending many more packets than the other — usually because one side is on PCM-with-Tight-ASIO and the other is on Opus. Fine in itself, but if the high-rate side's home upload is saturated it'll cause jitter. Switch that side to Opus 10 ms.
|
||||
|
||||
## Wire format reference (for anyone reading the relay code)
|
||||
|
||||
Header is 12 bytes, little-endian:
|
||||
|
||||
```
|
||||
uint32 magic 'RMND' (0x444E4D52)
|
||||
uint8 version 1
|
||||
uint8 type 1=Format 2=Audio 3=KeepAlive 4=Heartbeat
|
||||
uint16 streamId
|
||||
uint32 sequence
|
||||
```
|
||||
|
||||
The relay validates magic and version, reads the type for logging, and **does not interpret anything past byte 6**. Everything after the header is opaque payload.
|
||||
|
||||
## Security note
|
||||
|
||||
The relay has no authentication and the audio between peers is plaintext UDP. The trade-off is explicit: this is a music-collaboration tool, not a confidential channel. If you really need privacy, the right answer is to run Tailscale on both ends — that gives you WireGuard encryption end-to-end, at the cost of the latency overhead a relay aims to avoid.
|
||||
|
||||
## Questions and issues
|
||||
|
||||
Open an issue at <https://github.com/Ednunp/RemSound/issues>.
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/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.
|
||||
# 2. Copies remsound-relay.py to /usr/local/sbin/.
|
||||
# 3. Copies remsound-relay.service to /etc/systemd/system/.
|
||||
# 4. Creates an empty /var/log/remsound-relay.log if missing.
|
||||
# 5. systemctl daemon-reload, enable + start the service.
|
||||
# 6. Prints status and the last few log lines so you can see it's alive.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "This installer must run as root. Try: sudo ./install.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolve the directory this script lives in, regardless of where it was launched from.
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)"
|
||||
|
||||
if [[ ! -f "$SCRIPT_DIR/remsound-relay.py" || ! -f "$SCRIPT_DIR/remsound-relay.service" ]]; then
|
||||
echo "Cannot find remsound-relay.py and/or remsound-relay.service next to install.sh." >&2
|
||||
echo "Run the installer from inside the unzipped bundle folder." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v systemctl >/dev/null 2>&1; then
|
||||
echo "systemctl not found. This bundle expects a systemd-based Linux (Raspberry Pi OS, Debian, Ubuntu, etc.)." >&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
|
||||
|
||||
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 "Ensuring log file exists ..."
|
||||
touch /var/log/remsound-relay.log
|
||||
chown root:root /var/log/remsound-relay.log
|
||||
chmod 0644 /var/log/remsound-relay.log
|
||||
|
||||
echo "Reloading systemd, enabling, and starting remsound-relay ..."
|
||||
systemctl daemon-reload
|
||||
systemctl enable remsound-relay.service >/dev/null
|
||||
systemctl restart remsound-relay.service
|
||||
|
||||
# Give the service a beat to come up before we try to read its state.
|
||||
sleep 1
|
||||
|
||||
echo
|
||||
echo "=== service status ==="
|
||||
systemctl --no-pager --full status remsound-relay.service || true
|
||||
|
||||
echo
|
||||
echo "=== last 10 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 "Next step: open a UDP 47830 port-forward on your router toward this Pi's LAN address,"
|
||||
echo "then dial the Pi's public hostname:47830 from RemSound on each end."
|
||||
echo "See README.md for full operational notes."
|
||||
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
RemSound UDP relay.
|
||||
|
||||
Listens on a single UDP port and reflects RemSound packets between up to two
|
||||
peer endpoints. Validates the 12-byte RemSound header (magic 'RMND', version 1)
|
||||
and silently drops anything else. Never decodes audio.
|
||||
|
||||
Pairing model: pragmatic v1. The relay does not key on stream id (two RemSound
|
||||
instances will use different per-sender stream ids, so keying on it would
|
||||
prevent pairing). Instead the first two distinct UDP endpoints to send a valid
|
||||
RemSound packet claim the two peer slots; subsequent valid packets from a
|
||||
slot's endpoint are reflected to the other slot. Slots that go silent for more
|
||||
than IDLE_TIMEOUT_SECONDS are replaced when a fresh endpoint arrives.
|
||||
|
||||
Spec and operational notes: see README.md alongside this script.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import logging.handlers
|
||||
import select
|
||||
import signal
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
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
|
||||
SOCKET_POLL_TIMEOUT_SECONDS = 1.0
|
||||
DEFAULT_LOG_PATH = "/var/log/remsound-relay.log"
|
||||
|
||||
# Wire format constants — little-endian, see RemSound.Core.RemPacket.
|
||||
HEADER_LEN = 12
|
||||
MAGIC = b"RMND"
|
||||
VERSION = 1
|
||||
TYPE_FORMAT = 1
|
||||
TYPE_AUDIO = 2
|
||||
TYPE_KEEPALIVE = 3
|
||||
TYPE_HEARTBEAT = 4
|
||||
PACKET_TYPE_NAMES = {
|
||||
TYPE_FORMAT: "Format",
|
||||
TYPE_AUDIO: "Audio",
|
||||
TYPE_KEEPALIVE: "KeepAlive",
|
||||
TYPE_HEARTBEAT: "Heartbeat",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PeerSlot:
|
||||
addr: tuple[str, int]
|
||||
last_seen: float
|
||||
rx_packets: int = 0
|
||||
tx_packets: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class RelayStats:
|
||||
forwarded: int = 0
|
||||
dropped_unpaired: int = 0
|
||||
rejected_bad_header: int = 0
|
||||
pair_changes: int = 0
|
||||
|
||||
|
||||
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(data: bytes) -> Optional[tuple[int, int, int, int]]:
|
||||
"""Validate the RemSound header. Returns (version, type, stream_id, sequence) or None."""
|
||||
if len(data) < HEADER_LEN:
|
||||
return None
|
||||
if data[0:4] != MAGIC:
|
||||
return None
|
||||
version = data[4]
|
||||
if version != VERSION:
|
||||
return None
|
||||
pkt_type = data[5]
|
||||
stream_id = struct.unpack_from("<H", data, 6)[0]
|
||||
sequence = struct.unpack_from("<I", data, 8)[0]
|
||||
return version, pkt_type, stream_id, sequence
|
||||
|
||||
|
||||
class Relay:
|
||||
def __init__(self, sock: socket.socket, log: logging.Logger):
|
||||
self.sock = sock
|
||||
self.log = log
|
||||
self.peers: list[PeerSlot] = []
|
||||
self.stats = RelayStats()
|
||||
self.last_stats_log = time.monotonic()
|
||||
|
||||
@staticmethod
|
||||
def _fmt_addr(addr: tuple[str, int]) -> str:
|
||||
return f"{addr[0]}:{addr[1]}"
|
||||
|
||||
def find_slot(self, addr: tuple[str, int]) -> Optional[int]:
|
||||
for i, p in enumerate(self.peers):
|
||||
if p.addr == addr:
|
||||
return i
|
||||
return None
|
||||
|
||||
def expire_idle(self, now: float) -> None:
|
||||
if not self.peers:
|
||||
return
|
||||
kept: list[PeerSlot] = []
|
||||
dropped: list[tuple[str, int]] = []
|
||||
for p in self.peers:
|
||||
if (now - p.last_seen) <= IDLE_TIMEOUT_SECONDS:
|
||||
kept.append(p)
|
||||
else:
|
||||
dropped.append(p.addr)
|
||||
if dropped:
|
||||
self.peers = kept
|
||||
for addr in dropped:
|
||||
self.log.info(
|
||||
"event=peer_dropped reason=idle addr=%s remaining=%d",
|
||||
self._fmt_addr(addr),
|
||||
len(self.peers),
|
||||
)
|
||||
self.stats.pair_changes += 1
|
||||
|
||||
def admit_or_replace(self, addr: tuple[str, int], now: float) -> int:
|
||||
if len(self.peers) < 2:
|
||||
self.peers.append(PeerSlot(addr=addr, last_seen=now))
|
||||
self.log.info(
|
||||
"event=peer_joined addr=%s slots_filled=%d",
|
||||
self._fmt_addr(addr),
|
||||
len(self.peers),
|
||||
)
|
||||
self.stats.pair_changes += 1
|
||||
if len(self.peers) == 2:
|
||||
self.log.info(
|
||||
"event=peer_paired a=%s b=%s",
|
||||
self._fmt_addr(self.peers[0].addr),
|
||||
self._fmt_addr(self.peers[1].addr),
|
||||
)
|
||||
return len(self.peers) - 1
|
||||
# Both slots occupied. Replace the stalest one if it has been idle.
|
||||
oldest = 0 if self.peers[0].last_seen <= self.peers[1].last_seen else 1
|
||||
if (now - self.peers[oldest].last_seen) > IDLE_TIMEOUT_SECONDS:
|
||||
old_addr = self.peers[oldest].addr
|
||||
self.peers[oldest] = PeerSlot(addr=addr, last_seen=now)
|
||||
self.log.info(
|
||||
"event=peer_replaced old=%s new=%s",
|
||||
self._fmt_addr(old_addr),
|
||||
self._fmt_addr(addr),
|
||||
)
|
||||
self.stats.pair_changes += 1
|
||||
return oldest
|
||||
return -1 # Both slots active — packet from a third endpoint is ignored.
|
||||
|
||||
def handle_packet(self, data: bytes, addr: tuple[str, int]) -> None:
|
||||
parsed = parse_header(data)
|
||||
if parsed is None:
|
||||
self.stats.rejected_bad_header += 1
|
||||
return
|
||||
# We do not log per-packet detail (would flood the log). Stats covers it.
|
||||
_version, _pkt_type, _stream_id, _sequence = parsed
|
||||
|
||||
now = time.monotonic()
|
||||
idx = self.find_slot(addr)
|
||||
if idx is None:
|
||||
# Take the chance to age out idle slots first.
|
||||
self.expire_idle(now)
|
||||
idx = self.admit_or_replace(addr, now)
|
||||
if idx < 0:
|
||||
self.stats.dropped_unpaired += 1
|
||||
return
|
||||
|
||||
peer = self.peers[idx]
|
||||
peer.last_seen = now
|
||||
peer.rx_packets += 1
|
||||
|
||||
if len(self.peers) == 2:
|
||||
other = self.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 to=%s err=%s",
|
||||
self._fmt_addr(other.addr),
|
||||
e,
|
||||
)
|
||||
else:
|
||||
self.stats.dropped_unpaired += 1
|
||||
|
||||
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
|
||||
peers_summary = ", ".join(
|
||||
f"{self._fmt_addr(p.addr)}(rx={p.rx_packets},tx={p.tx_packets})"
|
||||
for p in self.peers
|
||||
) or "none"
|
||||
self.log.info(
|
||||
"event=stats forwarded=%d dropped_unpaired=%d rejected_bad_header=%d pair_changes=%d peers=[%s]",
|
||||
s.forwarded,
|
||||
s.dropped_unpaired,
|
||||
s.rejected_bad_header,
|
||||
s.pair_changes,
|
||||
peers_summary,
|
||||
)
|
||||
# Reset counters so the next stats line shows a per-minute rate.
|
||||
self.stats = RelayStats()
|
||||
for p in self.peers:
|
||||
p.rx_packets = 0
|
||||
p.tx_packets = 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="RemSound UDP relay")
|
||||
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})",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
log = setup_logger(args.log_path)
|
||||
log.info("event=startup version=1 listen=%s:%d", args.host, args.port)
|
||||
|
||||
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)
|
||||
|
||||
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.expire_idle(now)
|
||||
relay.maybe_log_stats(now)
|
||||
finally:
|
||||
log.info("event=shutdown")
|
||||
sock.close()
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -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
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/bin/bash
|
||||
# smoke-test.sh — Quick health check for the RemSound relay.
|
||||
#
|
||||
# Run after install. Confirms the service is running, listening on UDP 47830,
|
||||
# and accepts a valid RemSound header (silently dropping it as unpaired, which
|
||||
# is the expected behaviour with no real peers connected).
|
||||
#
|
||||
# Run as a regular user; only the log read at the end needs sudo and that
|
||||
# step degrades gracefully if you don't have it.
|
||||
|
||||
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. 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 a synthetic valid RemSound header from this machine to localhost,
|
||||
# confirm the relay accepts it (it'll be admitted to slot 1 and then
|
||||
# dropped as unpaired, which logs an event=peer_joined line).
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 - <<'PY'
|
||||
import socket
|
||||
# Magic 'RMND' (little-endian 0x444E4D52), version 1, type 4 (Heartbeat),
|
||||
# stream id 1, sequence 1.
|
||||
header = bytes([0x52, 0x4D, 0x4E, 0x44, 1, 4, 1, 0, 1, 0, 0, 0])
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.sendto(header, ("127.0.0.1", 47830))
|
||||
s.close()
|
||||
PY
|
||||
ok "sent synthetic valid RemSound header to 127.0.0.1:47830"
|
||||
else
|
||||
warn "python3 not installed, skipping header send"
|
||||
fi
|
||||
|
||||
# 4. Look for the resulting peer_joined event in the relay log.
|
||||
sleep 1
|
||||
if [[ -r /var/log/remsound-relay.log ]]; then
|
||||
if tail -20 /var/log/remsound-relay.log 2>/dev/null | grep -q "event=peer_joined.*127.0.0.1"; then
|
||||
ok "relay logged a peer_joined event for 127.0.0.1 — header validation works"
|
||||
else
|
||||
warn "no peer_joined event for 127.0.0.1 found in last 20 log lines (may have aged out if you ran this twice)"
|
||||
fi
|
||||
elif sudo -n test -r /var/log/remsound-relay.log 2>/dev/null; then
|
||||
if sudo tail -20 /var/log/remsound-relay.log 2>/dev/null | grep -q "event=peer_joined.*127.0.0.1"; then
|
||||
ok "relay logged a peer_joined event for 127.0.0.1 — header validation works"
|
||||
else
|
||||
warn "no peer_joined event for 127.0.0.1 found in last 20 log lines"
|
||||
fi
|
||||
else
|
||||
warn "cannot read /var/log/remsound-relay.log (try: sudo $0 to also read the log)"
|
||||
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
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
# uninstall.sh — Remove the RemSound UDP relay cleanly.
|
||||
#
|
||||
# Run with sudo:
|
||||
# sudo ./uninstall.sh
|
||||
#
|
||||
# Stops the service, disables it, removes the script and unit file, and
|
||||
# leaves the log file in place (rename or delete it yourself if you don't
|
||||
# want it 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
|
||||
|
||||
if systemctl list-unit-files remsound-relay.service >/dev/null 2>&1; then
|
||||
echo "Stopping remsound-relay ..."
|
||||
systemctl stop remsound-relay.service || true
|
||||
echo "Disabling remsound-relay ..."
|
||||
systemctl disable remsound-relay.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if [[ -f /etc/systemd/system/remsound-relay.service ]]; then
|
||||
echo "Removing /etc/systemd/system/remsound-relay.service ..."
|
||||
rm -f /etc/systemd/system/remsound-relay.service
|
||||
fi
|
||||
|
||||
if [[ -f /usr/local/sbin/remsound-relay.py ]]; then
|
||||
echo "Removing /usr/local/sbin/remsound-relay.py ..."
|
||||
rm -f /usr/local/sbin/remsound-relay.py
|
||||
fi
|
||||
|
||||
systemctl daemon-reload
|
||||
|
||||
echo
|
||||
echo "Uninstall complete."
|
||||
echo "Note: /var/log/remsound-relay.log is left in place — delete it yourself if you don't want it."
|
||||
echo "Note: any router port-forward you added for UDP 47830 is unchanged — remove that yourself if you no longer need it."
|
||||
Reference in New Issue
Block a user