diff --git a/src/RemSound.App/MainForm.cs b/src/RemSound.App/MainForm.cs index f036782..69a2c5b 100644 --- a/src/RemSound.App/MainForm.cs +++ b/src/RemSound.App/MainForm.cs @@ -5682,7 +5682,21 @@ public sealed class MainForm : Form /// private void PushAllowedReceiveSenders() { - receiver.SetAllowedSenders(SelectedSendEndpoints()); + // Accept audio from ANY source IP a selected peer is known to use, not only the single address + // we currently target. A multi-homed sender (on a LAN and a VPN at once) can egress audio from a + // different interface than the one we discovered or dialled; allow-listing just the one made the + // receiver silently drop that audio while heartbeats (which skip this check) kept the peer + // looking connected — connected but silent (#18). The SEND targets stay single-address; only the + // accept-list widens, and only to other addresses the SAME peer (by InstanceId) announced from. + var allowed = new List(); + var seen = new HashSet(); + foreach (var (id, ep) in selectedPeerEndpoints) + { + if (seen.Add(ep.Address)) allowed.Add(new IPEndPoint(ep.Address, 0)); + foreach (var addr in discovery.GetKnownAddresses(id)) + if (seen.Add(addr)) allowed.Add(new IPEndPoint(addr, 0)); + } + receiver.SetAllowedSenders(allowed); } /// True if the heartbeat currently considers healthy — diff --git a/src/RemSound.Core/PeerDiscoveryService.cs b/src/RemSound.Core/PeerDiscoveryService.cs index 0a8b167..1b89ff3 100644 --- a/src/RemSound.Core/PeerDiscoveryService.cs +++ b/src/RemSound.Core/PeerDiscoveryService.cs @@ -25,6 +25,11 @@ public sealed class PeerDiscoveryService : IDisposable private readonly Guid instanceId = Guid.NewGuid(); private readonly object gate = new(); private readonly Dictionary 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> 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); + } + } + + /// 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). + public IReadOnlyCollection 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);