Support IPv6 addresses for direct SRTP calls

Extend shared endpoint validation, interface discovery and native media sockets to IPv6. Add real bidirectional IPv6 SRTP, wrong-key rejection and port lifecycle tests, and document connectivity limitations.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-11-02 15:33:03 +00:00
commit a39c3912ea
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
9 changed files with 108 additions and 52 deletions

View file

@ -114,13 +114,16 @@ public sealed class LanCallViewModelTests
return vm;
}
[AvaloniaFact]
public async Task Public_ipv4_can_be_selected_for_an_explicitly_consented_direct_call()
[AvaloniaTheory]
[InlineData("8.8.8.8")]
[InlineData("2001:db8::1")]
[InlineData("fd00::1")]
public async Task Public_or_private_ip_can_be_selected_for_an_explicitly_consented_direct_call(string address)
{
var signaling = new Signaling();
await using var vm = Configure(signaling, (_, _) => throw new Exception("No media before acceptance"));
vm.Addresses.Add("8.8.8.8");
vm.Address = "8.8.8.8";
vm.Addresses.Add(address);
vm.Address = address;
await vm.InviteCommand.ExecuteAsync(null);
Assert.Equal(1, signaling.Invites);
Assert.True(vm.Busy);

View file

@ -15,38 +15,45 @@ public sealed class LinuxLanCallMediaTests(ITestOutputHelper output)
public static bool HasTsFixture => OperatingSystem.IsLinux() &&
File.Exists(Environment.GetEnvironmentVariable("POSTIT_TEST_TS"));
[Fact(Skip = "Requires Linux LibVLC.", SkipUnless = nameof(HasFixture))]
public async Task Ports_are_released_and_credentials_regenerated_after_disposal()
[Theory(Skip = "Requires Linux LibVLC.", SkipUnless = nameof(HasFixture))]
[InlineData("127.0.0.1")]
[InlineData("::1")]
public async Task Ports_are_released_and_credentials_regenerated_after_disposal(string address)
{
var port = FreePort();
var first = new LibVlcLanCallMedia("127.0.0.1", port, headless: true);
var port = FreePort(address: address);
var first = new LibVlcLanCallMedia(address, port, headless: true);
var endpoint = first.Endpoint;
await first.StartReceivingAsync(TestContext.Current.CancellationToken);
await first.DisposeAsync();
await first.DisposeAsync();
await using var next = new LibVlcLanCallMedia("127.0.0.1", port, headless: true);
await using var next = new LibVlcLanCallMedia(address, port, headless: true);
Assert.NotEqual(endpoint.Key, next.Endpoint.Key);
Assert.NotEqual(endpoint.Salt, next.Endpoint.Salt);
await next.StartReceivingAsync(TestContext.Current.CancellationToken);
}
[Fact(Skip = "Requires Linux LibVLC.", SkipUnless = nameof(HasFixture))]
public void Occupied_rtcp_port_is_rejected_before_capture_starts()
[Theory(Skip = "Requires Linux LibVLC.", SkipUnless = nameof(HasFixture))]
[InlineData("127.0.0.1")]
[InlineData("::1")]
public void Occupied_rtcp_port_is_rejected_before_capture_starts(string address)
{
var port = FreePort();
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
socket.Bind(new IPEndPoint(IPAddress.Loopback, port + 1));
var error = Assert.Throws<SocketException>(() => new LibVlcLanCallMedia("127.0.0.1", port, headless: true));
var port = FreePort(address: address);
var ip = IPAddress.Parse(address);
using var socket = new Socket(ip.AddressFamily, SocketType.Dgram, ProtocolType.Udp);
socket.Bind(new IPEndPoint(ip, port + 1));
var error = Assert.Throws<SocketException>(() => new LibVlcLanCallMedia(address, port, headless: true));
Assert.Equal(SocketError.AddressAlreadyInUse, error.SocketErrorCode);
}
[Fact(Skip = "Set POSTIT_TEST_TS to an H.264/AAC MPEG-TS file.", SkipUnless = nameof(HasTsFixture))]
public async Task Already_encoded_capture_is_sent_over_srtp_without_transcoding()
[Theory(Skip = "Set POSTIT_TEST_TS to an H.264/AAC MPEG-TS file.", SkipUnless = nameof(HasTsFixture))]
[InlineData("127.0.0.1")]
[InlineData("::1")]
public async Task Already_encoded_capture_is_sent_over_srtp_without_transcoding(string address)
{
var ct = TestContext.Current.CancellationToken;
var firstPort = FreePort();
await using var sender = new LibVlcLanCallMedia("127.0.0.1", firstPort, headless: true);
await using var receiver = new LibVlcLanCallMedia("127.0.0.1", FreePort(firstPort), headless: true);
var firstPort = FreePort(address: address);
await using var sender = new LibVlcLanCallMedia(address, firstPort, headless: true);
await using var receiver = new LibVlcLanCallMedia(address, FreePort(firstPort, address), headless: true);
using var decoded = new DecodedVideo(receiver.Receiver);
using var pipe = new AnonymousPipeServerStream(PipeDirection.Out);
await using var source = File.OpenRead(Environment.GetEnvironmentVariable("POSTIT_TEST_TS")!);
@ -88,13 +95,16 @@ public sealed class LinuxLanCallMediaTests(ITestOutputHelper output)
[InlineData(false, false)]
[InlineData(true, false)]
[InlineData(false, true)]
public async Task Two_peers_decode_bidirectional_encrypted_audio_video_and_reject_wrong_keys(bool wrongKey, bool preview)
[InlineData(false, false, "::1")]
[InlineData(true, false, "::1")]
public async Task Two_peers_decode_bidirectional_encrypted_audio_video_and_reject_wrong_keys(
bool wrongKey, bool preview, string address = "127.0.0.1")
{
var ct = TestContext.Current.CancellationToken;
var firstPort = FreePort();
var secondPort = FreePort(firstPort);
await using var first = new LibVlcLanCallMedia("127.0.0.1", firstPort, headless: true);
await using var second = new LibVlcLanCallMedia("127.0.0.1", secondPort, headless: true);
var firstPort = FreePort(address: address);
var secondPort = FreePort(firstPort, address);
await using var first = new LibVlcLanCallMedia(address, firstPort, headless: true);
await using var second = new LibVlcLanCallMedia(address, secondPort, headless: true);
using var firstVideo = new DecodedVideo(first.Receiver);
using var secondVideo = new DecodedVideo(second.Receiver);
await first.StartReceivingAsync(ct);
@ -142,17 +152,18 @@ public sealed class LinuxLanCallMediaTests(ITestOutputHelper output)
private static bool HasMedia(LibVlcLanCallMedia peer, DecodedVideo video) =>
peer.Statistics.DecodedAudio > 0 && video.AudioSamples > 4800 && video.Frames >= 10;
private static int FreePort(int except = 0)
private static int FreePort(int except = 0, string address = "127.0.0.1")
{
var ip = IPAddress.Parse(address);
for (var port = 32000; port < 60000; port += 2)
{
if (port == except) continue;
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
using var rtcp = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
using var socket = new Socket(ip.AddressFamily, SocketType.Dgram, ProtocolType.Udp);
using var rtcp = new Socket(ip.AddressFamily, SocketType.Dgram, ProtocolType.Udp);
try
{
socket.Bind(new IPEndPoint(IPAddress.Loopback, port));
rtcp.Bind(new IPEndPoint(IPAddress.Loopback, port + 1));
socket.Bind(new IPEndPoint(ip, port));
rtcp.Bind(new IPEndPoint(ip, port + 1));
return port;
}
catch (SocketException ex) when (ex.SocketErrorCode == SocketError.AddressAlreadyInUse) { }

View file

@ -35,9 +35,10 @@ public sealed class LibVlcLanCallMedia : IAsyncDisposable
Endpoint = new(localAddress, port, Convert.ToHexString(RandomNumberGenerator.GetBytes(16)),
Convert.ToHexString(RandomNumberGenerator.GetBytes(14)));
if (!Endpoint.IsValid())
throw new ArgumentException("Adresse IPv4 unicast et port UDP pair (1024-65534) requis.");
using (var reservation = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp))
using (var rtcp = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp))
throw new ArgumentException("Adresse IPv4/IPv6 unicast et port UDP pair (1024-65534) requis.");
var localIp = IPAddress.Parse(localAddress);
using (var reservation = new Socket(localIp.AddressFamily, SocketType.Dgram, ProtocolType.Udp))
using (var rtcp = new Socket(localIp.AddressFamily, SocketType.Dgram, ProtocolType.Udp))
{
reservation.Bind(new IPEndPoint(IPAddress.Parse(localAddress), port));
rtcp.Bind(new IPEndPoint(IPAddress.Parse(localAddress), port + 1));
@ -51,7 +52,8 @@ public sealed class LibVlcLanCallMedia : IAsyncDisposable
: new LibVLC("--avcodec-hw=none", "--intf=dummy");
try
{
_receiverMedia = new Media(_receiverVlc, $"rtp://@{localAddress}:{port}", FromType.FromLocation);
var host = localIp.AddressFamily == AddressFamily.InterNetworkV6 ? $"[{localAddress}]" : localAddress;
_receiverMedia = new Media(_receiverVlc, $"rtp://@{host}:{port}", FromType.FromLocation);
_receiverMedia.AddOption(":network-caching=150");
_receiverMedia.AddOption(":rtcp-port=" + (port + 1));
_receiverMedia.AddOption(":srtp-key=" + Endpoint.Key);

View file

@ -116,7 +116,7 @@ public partial class LanCallViewModel : ObservableObject, IAsyncDisposable
foreach (var address in NetworkInterface.GetAllNetworkInterfaces()
.Where(n => n.OperationalStatus == OperationalStatus.Up)
.SelectMany(n => n.GetIPProperties().UnicastAddresses)
.Select(a => a.Address.ToString()).Where(LanCallNetwork.IsUnicastIPv4)
.Select(a => a.Address.ToString()).Where(LanCallNetwork.IsUnicastAddress)
.Distinct().OrderBy(a => IPAddress.IsLoopback(IPAddress.Parse(a))))
Addresses.Add(address);
Address = preferred == null ? Addresses.FirstOrDefault() : Addresses.FirstOrDefault(a => a == preferred);
@ -181,7 +181,7 @@ public partial class LanCallViewModel : ObservableObject, IAsyncDisposable
Status = Cameras.Count == 0 ? "Aucune camera detectee. Verifiez les peripheriques puis actualisez."
: Incoming && _call != null
? $"{_call.Caller} propose un appel dans {_call.RoomName}. Selectionnez vos peripheriques puis acceptez ou refusez."
: "Selectionnez le pair, l'adresse IPv4 locale ou publique et les peripheriques. Utilisez un casque.";
: "Selectionnez le pair, l'adresse IPv4/IPv6 locale ou publique et les peripheriques. Utilisez un casque.";
if (_settings != null)
{
_settings.PodcastCameras = Cameras.ToArray();
@ -200,10 +200,10 @@ public partial class LanCallViewModel : ObservableObject, IAsyncDisposable
private void ValidateSelection()
{
if (!AllowAddressDisclosure)
throw new InvalidOperationException("Confirmez le partage de votre adresse IPv4 avec le pair.");
throw new InvalidOperationException("Confirmez le partage de votre adresse IP avec le pair.");
if (Camera == null || Microphone == null)
throw new InvalidOperationException("Une camera et un microphone sont requis.");
if (Address == null || !Addresses.Contains(Address) || !LanCallNetwork.IsUnicastIPv4(Address) ||
if (Address == null || !Addresses.Contains(Address) || !LanCallNetwork.IsUnicastAddress(Address) ||
!int.TryParse(Port, out var port) || port is < 1024 or > 65534 || port % 2 != 0)
throw new InvalidOperationException("Selectionnez une adresse locale et un port UDP pair (1024-65534).");
_activeCamera = Camera.Value;

View file

@ -103,16 +103,16 @@
Content="Envoyer" />
</Grid>
<Expander Grid.Row="3" Header="Appel direct IPv4 (LAN/VPN ou IP publique)" IsVisible="{Binding SupportsLanCalls}" IsExpanded="True"
<Expander Grid.Row="3" Header="Appel direct IPv4/IPv6 (LAN/VPN ou IP publique)" IsVisible="{Binding SupportsLanCalls}" IsExpanded="True"
IsEnabled="{Binding IsConnected}" Margin="0,0,0,8">
<ScrollViewer MaxHeight="520">
<StackPanel DataContext="{Binding LanCall}" x:DataType="vm:LanCallViewModel" Spacing="8">
<TextBlock Text="Android et Desktop Linux : IPv4 locale LAN/VPN ou publique directement assignee au PC. SRTP chiffre les medias ; le pair connait votre IP. Ouvrir le port UDP choisi et le suivant. Sans TURN ni traversee NAT. Casque recommande (pas d'annulation d'echo)."
<TextBlock Text="Android et Desktop Linux : IPv4 ou IPv6 globale/privee directement assignee a la machine (hors IPv6 link-local). Les pairs doivent pouvoir joindre leurs adresses. SRTP chiffre les medias ; le pair connait votre IP. Ouvrir le port UDP choisi et le suivant. Sans TURN ni traversee NAT. Casque recommande (pas d'annulation d'echo)."
TextWrapping="Wrap" />
<WrapPanel IsEnabled="{Binding CanConfigure}" IsVisible="{Binding !RemoteVideo}">
<Button Content="Actualiser les peripheriques" Command="{Binding RefreshDevicesCommand}" Margin="0,0,8,4" />
<ComboBox Width="160" ItemsSource="{Binding Addresses}" SelectedItem="{Binding Address}"
PlaceholderText="Adresse IPv4 de cette machine" Margin="0,0,8,4" />
PlaceholderText="Adresse IPv4/IPv6 de cette machine" Margin="0,0,8,4" />
<TextBox Width="80" Text="{Binding Port}" PlaceholderText="Port pair" Margin="0,0,8,4" />
<ComboBox Width="190" ItemsSource="{Binding Cameras}" SelectedItem="{Binding Camera}"
PlaceholderText="Camera" Margin="0,0,8,4">
@ -132,7 +132,7 @@
</ComboBox>
<TextBox Width="170" Text="{Binding Target}" PlaceholderText="Pseudo du pair" Margin="0,0,8,4" />
</WrapPanel>
<CheckBox Content="Je consens a partager mon adresse IPv4 avec le pair."
<CheckBox Content="Je consens a partager mon adresse IP avec le pair."
IsChecked="{Binding AllowAddressDisclosure}" IsEnabled="{Binding CanConfigure}"
IsVisible="{Binding !RemoteVideo}" />
<WrapPanel>