Fix #18: accept audio from all of a multi-homed sender's source IPs

A sender reachable at more than one IP at once (e.g. LAN + Tailscale/VPN) picks its own
egress interface per packet, so its audio can arrive from a different IP than the single
address we discovered/dialled and allow-listed. The receiver then silently dropped every
Format/Audio packet (packetsRejectedNotAllowed climbing) while heartbeats — which skip the
allow-list — kept the peer showing connected: connected but silent. (Reported by
Jonathans859 building the RemSoundApple client; receiver-side, affects any multi-homed
sender incl. Windows<->Windows over a VPN.)

Discovery now remembers ALL source IPs per peer InstanceId (PeerDiscoveryService
.addressesById, expired on the same 8 s window; GetKnownAddresses). PushAllowedReceiveSenders
unions each selected peer's endpoint address with every address that peer has announced from,
so audio from any of the peer's interfaces is accepted. The SEND targets are unchanged
(still single-address) — only the accept-list widens, and only to other addresses the SAME
peer (by InstanceId) announced from, so it can't accept an unrelated machine.

Held for next release.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ednunp
2026-07-04 14:26:42 +01:00
co-authored by Claude Opus 4.8
parent 1c1bf5a5cb
commit 703e6a022d
2 changed files with 48 additions and 1 deletions
+33
View File
@@ -25,6 +25,11 @@ public sealed class PeerDiscoveryService : IDisposable
private readonly Guid instanceId = Guid.NewGuid();
private readonly object gate = new();
private readonly Dictionary<Guid, PeerAnnouncement> peers = [];
// All source IPs each peer (by InstanceId) has recently announced from, with last-seen for expiry.
// A multi-homed peer (LAN + VPN at once) announces from several interfaces; we keep them all so the
// receiver can accept audio from whichever one a given packet egresses from (#18), not only the
// last-seen address. Guarded by gate; pruned on the same 8 s window as `peers`.
private readonly Dictionary<Guid, Dictionary<IPAddress, DateTime>> addressesById = [];
private CancellationTokenSource? cts;
private UdpClient? listener;
private UdpClient? announcer;
@@ -178,6 +183,13 @@ public sealed class PeerDiscoveryService : IDisposable
|| existing.CanReceive != peer.CanReceive
|| !Equals(existing.Address, peer.Address);
peers[peer.InstanceId] = peer;
// Remember this source IP for the peer (multi-homed senders announce from several).
if (!addressesById.TryGetValue(peer.InstanceId, out var addrs))
{
addrs = [];
addressesById[peer.InstanceId] = addrs;
}
addrs[peer.Address] = peer.LastSeenUtc;
PruneExpiredPeers();
}
if (changed) PeersChanged?.Invoke();
@@ -300,6 +312,27 @@ public sealed class PeerDiscoveryService : IDisposable
{
peers.Remove(peer.InstanceId);
}
// Expire per-interface source addresses on the same window, and drop any peer left with none.
foreach (var (id, addrs) in addressesById.ToList())
{
foreach (var addr in addrs.Where(kv => kv.Value < cutoff).Select(kv => kv.Key).ToList())
{
addrs.Remove(addr);
}
if (addrs.Count == 0) addressesById.Remove(id);
}
}
/// <summary>All source IPs a peer (by InstanceId) has announced from within the expiry window. A
/// multi-homed peer announces from several; the receiver allow-lists all of them so audio egressing
/// from any of the peer's interfaces is accepted rather than silently dropped (#18).</summary>
public IReadOnlyCollection<IPAddress> GetKnownAddresses(Guid instanceId)
{
lock (gate)
{
PruneExpiredPeers();
return addressesById.TryGetValue(instanceId, out var addrs) ? addrs.Keys.ToArray() : [];
}
}
private sealed record DiscoveryMessage(Guid InstanceId, string Name, int AudioPort, bool CanSend, bool CanReceive);