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

@ -5,6 +5,7 @@
### Added ### Added
* [PostIt/Calls] Support direct IPv6 SRTP calls using global, ULA and loopback addresses, shared endpoint validation, IPv6 UDP sockets and bracketed reception URLs. Exclude scoped/link-local, multicast, unspecified and IPv4-mapped addresses; verify real bidirectional IPv6 audio/video, wrong-key rejection and port lifecycle with LibVLC.
* [PostIt/Calls] Allow directly assigned public IPv4 addresses for direct SRTP calls, alongside LAN/VPN and loopback addresses. Share unicast validation between interface discovery, media and server signaling; continue rejecting unspecified, link-local, multicast, reserved and IPv6 addresses. Keep explicit address-disclosure consent and no automatic NAT traversal. * [PostIt/Calls] Allow directly assigned public IPv4 addresses for direct SRTP calls, alongside LAN/VPN and loopback addresses. Share unicast validation between interface discovery, media and server signaling; continue rejecting unspecified, link-local, multicast, reserved and IPv6 addresses. Keep explicit address-disclosure consent and no automatic NAT traversal.
* [PostIt] Update bundled settings with the default podcast title, camera/microphone preferences and French language selection. * [PostIt] Update bundled settings with the default podcast title, camera/microphone preferences and French language selection.

View file

@ -5,7 +5,7 @@
Ce document décrit l'architecture cible et l'implémentation commencée. Ce document décrit l'architecture cible et l'implémentation commencée.
La préférence de confidentialité est persistée et éditable dans le profil. La préférence de confidentialité est persistée et éditable dans le profil.
La signalisation est intégrée au chat, désactivée par défaut. Un premier La signalisation est intégrée au chat, désactivée par défaut. Un premier
transport natif **Android et Desktop Linux en IPv4 directe (LAN/VPN ou IP publique)** est raccordé : transport natif **Android et Desktop Linux en IPv4/IPv6 directe (LAN/VPN ou IP publique)** est raccordé :
captures Podcast existantes et SRTP direct, sans libwebrtc ni nouvelle captures Podcast existantes et SRTP direct, sans libwebrtc ni nouvelle
couche JNI WebRTC. Il ne réalise pas encore la cible Internet, TURN ou groupe. couche JNI WebRTC. Il ne réalise pas encore la cible Internet, TURN ou groupe.
Il complète la [topologie de PostIt](postit.md) et les Il complète la [topologie de PostIt](postit.md) et les
@ -18,17 +18,28 @@ La cible couvre :
- les appels de groupe ; - les appels de groupe ;
- les clients Browser, Android et Desktop. - les clients Browser, Android et Desktop.
## Première implémentation Android et Desktop Linux : SRTP direct IPv4 ## Première implémentation Android et Desktop Linux : SRTP direct IPv4/IPv6
Les adresses IPv4 publiques directement assignees a une interface locale active Les adresses IPv4 publiques directement assignees a une interface locale active
sont egalement disponibles, avec la meme validation unicast cote client, sont egalement disponibles, avec la meme validation unicast cote client,
media et signalisation serveur. Les adresses non specifiees, link-local, media et signalisation serveur. Les IPv6 globales (2000::/3), privees ULA
multicast, reservees (240/4), IPv6 et les formes non canoniques sont refusees. (fc00::/7) et de boucle locale (::1) sont aussi acceptees. Les sockets UDP
utilisent la famille de l'adresse locale et les URL de reception encadrent
les IPv6 de crochets. Les adresses non specifiees, link-local, multicast,
reservees IPv4 (240/4), IPv4-mapped IPv6 et les formes non canoniques ou
avec identifiant de zone sont refusees. Une zone link-local du premier
pair n'est pas reutilisable telle quelle par le second.
Il faut autoriser en entree les deux ports UDP consecutifs choisis et assurer Il faut autoriser en entree les deux ports UDP consecutifs choisis et assurer
la joignabilite entre les pairs. Ce support n'ajoute ni ICE/TURN ni traversee la joignabilite entre les pairs. Ce support n'ajoute ni ICE/TURN ni traversee
NAT automatique : l'adresse publique d'un routeur ne remplace pas une adresse NAT automatique : l'adresse publique d'un routeur ne remplace pas une adresse
assignee a la machine. Le consentement explicite au partage d'IP et le refus assignee a la machine. Le consentement explicite au partage d'IP et le refus
du mode direct pour les comptes masquant leur IP restent inchanges. du mode direct pour les comptes masquant leur IP restent inchanges.
Les pairs doivent disposer de routes vers leurs adresses respectives ;
aucune traduction automatique IPv4/IPv6 n'est fournie. Les tests natifs
Linux verifient sur ::1 la reception audio/video bidirectionnelle chiffree,
le rejet d'une mauvaise cle et le cycle de vie des ports RTP/RTCP.
Un appel IPv6 entre deux machines et le transport IPv6 Android restent
a valider en conditions reelles.
Cette tranche réutilise la capture caméra/microphone et les encodeurs des Cette tranche réutilise la capture caméra/microphone et les encodeurs des
Podcasts, mais **pas leur segmentation HLS ni leurs envois de fichiers** : Podcasts, mais **pas leur segmentation HLS ni leurs envois de fichiers** :
@ -77,7 +88,7 @@ de fichier média temporaire ou de moteur WebRTC Android ajouté.
H.264/AAC. La version Debian 13 a été utilisée pour les essais Linux. H.264/AAC. La version Debian 13 a été utilisée pour les essais Linux.
Les deux comptes doivent être présents dans Les deux comptes doivent être présents dans
la même salle existante et ne pas se bloquer mutuellement. la même salle existante et ne pas se bloquer mutuellement.
3. Dans « Appel direct IPv4 (LAN/VPN ou IP publique) », actualiser les périphériques, 3. Dans « Appel direct IPv4/IPv6 (LAN/VPN ou IP publique) », actualiser les périphériques,
sélectionner la caméra, le microphone et une IPv4 privée locale sélectionner la caméra, le microphone et une IPv4 privée locale
accessible depuis le pair. Les adresses RFC1918 et de bouclage sont accessible depuis le pair. Les adresses RFC1918 et de bouclage sont
acceptées ; pas de DNS, IPv6 ou adresse publique. Le bouclage sert aux acceptées ; pas de DNS, IPv6 ou adresse publique. Le bouclage sert aux
@ -89,7 +100,7 @@ de fichier média temporaire ou de moteur WebRTC Android ajouté.
adresse doivent utiliser des paires distinctes. adresse doivent utiliser des paires distinctes.
Aucun pare-feu n'est modifié automatiquement. Aucun pare-feu n'est modifié automatiquement.
5. Saisir le pseudo exact du destinataire, confirmer la divulgation de 5. Saisir le pseudo exact du destinataire, confirmer la divulgation de
l'adresse IPv4 locale (LAN/VPN ou publique), puis appeler. Le destinataire choisit ses propres l'adresse IP locale (LAN/VPN ou publique), puis appeler. Le destinataire choisit ses propres
périphériques et son adresse, confirme et accepte explicitement. périphériques et son adresse, confirme et accepte explicitement.
Aucun accès à la caméra/microphone ne commence avant l'acceptation ; Aucun accès à la caméra/microphone ne commence avant l'acceptation ;
l'émission attend également la destination validée du pair. l'émission attend également la destination validée du pair.
@ -150,7 +161,7 @@ l'ajout d'un moteur WebRTC ou d'une cryptographie propre au projet.
Il n'y a aucun repli direct contournant cette préférence. Il n'y a aucun repli direct contournant cette préférence.
- Pas d'ICE, de STUN, de TURN, de traversée NAT automatique, de groupe, - Pas d'ICE, de STUN, de TURN, de traversée NAT automatique, de groupe,
d'interopérabilité Browser, de négociation de codecs ni d'interopérabilité Browser, de négociation de codecs ni
d'adaptation de débit. Ce mode exige une connectivite IPv4 directe entre les pairs d'adaptation de débit. Ce mode exige une connectivite IP directe entre les pairs
(LAN/VPN ou adresse publique assignee), avec les ports UDP ouverts. (LAN/VPN ou adresse publique assignee), avec les ports UDP ouverts.
- H.264 à 30 images/s, cible 1 200 kbit/s ; AAC mono 48 kHz, 96 kbit/s. - H.264 à 30 images/s, cible 1 200 kbit/s ; AAC mono 48 kHz, 96 kbit/s.
Ces réglages ne constituent pas une garantie de latence ni de débit. Ces réglages ne constituent pas une garantie de latence ni de débit.

View file

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

View file

@ -15,38 +15,45 @@ public sealed class LinuxLanCallMediaTests(ITestOutputHelper output)
public static bool HasTsFixture => OperatingSystem.IsLinux() && public static bool HasTsFixture => OperatingSystem.IsLinux() &&
File.Exists(Environment.GetEnvironmentVariable("POSTIT_TEST_TS")); File.Exists(Environment.GetEnvironmentVariable("POSTIT_TEST_TS"));
[Fact(Skip = "Requires Linux LibVLC.", SkipUnless = nameof(HasFixture))] [Theory(Skip = "Requires Linux LibVLC.", SkipUnless = nameof(HasFixture))]
public async Task Ports_are_released_and_credentials_regenerated_after_disposal() [InlineData("127.0.0.1")]
[InlineData("::1")]
public async Task Ports_are_released_and_credentials_regenerated_after_disposal(string address)
{ {
var port = FreePort(); var port = FreePort(address: address);
var first = new LibVlcLanCallMedia("127.0.0.1", port, headless: true); var first = new LibVlcLanCallMedia(address, port, headless: true);
var endpoint = first.Endpoint; var endpoint = first.Endpoint;
await first.StartReceivingAsync(TestContext.Current.CancellationToken); await first.StartReceivingAsync(TestContext.Current.CancellationToken);
await first.DisposeAsync(); await first.DisposeAsync();
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.Key, next.Endpoint.Key);
Assert.NotEqual(endpoint.Salt, next.Endpoint.Salt); Assert.NotEqual(endpoint.Salt, next.Endpoint.Salt);
await next.StartReceivingAsync(TestContext.Current.CancellationToken); await next.StartReceivingAsync(TestContext.Current.CancellationToken);
} }
[Fact(Skip = "Requires Linux LibVLC.", SkipUnless = nameof(HasFixture))] [Theory(Skip = "Requires Linux LibVLC.", SkipUnless = nameof(HasFixture))]
public void Occupied_rtcp_port_is_rejected_before_capture_starts() [InlineData("127.0.0.1")]
[InlineData("::1")]
public void Occupied_rtcp_port_is_rejected_before_capture_starts(string address)
{ {
var port = FreePort(); var port = FreePort(address: address);
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); var ip = IPAddress.Parse(address);
socket.Bind(new IPEndPoint(IPAddress.Loopback, port + 1)); using var socket = new Socket(ip.AddressFamily, SocketType.Dgram, ProtocolType.Udp);
var error = Assert.Throws<SocketException>(() => new LibVlcLanCallMedia("127.0.0.1", port, headless: true)); socket.Bind(new IPEndPoint(ip, port + 1));
var error = Assert.Throws<SocketException>(() => new LibVlcLanCallMedia(address, port, headless: true));
Assert.Equal(SocketError.AddressAlreadyInUse, error.SocketErrorCode); Assert.Equal(SocketError.AddressAlreadyInUse, error.SocketErrorCode);
} }
[Fact(Skip = "Set POSTIT_TEST_TS to an H.264/AAC MPEG-TS file.", SkipUnless = nameof(HasTsFixture))] [Theory(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() [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 ct = TestContext.Current.CancellationToken;
var firstPort = FreePort(); var firstPort = FreePort(address: address);
await using var sender = new LibVlcLanCallMedia("127.0.0.1", firstPort, headless: true); await using var sender = new LibVlcLanCallMedia(address, firstPort, headless: true);
await using var receiver = new LibVlcLanCallMedia("127.0.0.1", FreePort(firstPort), headless: true); await using var receiver = new LibVlcLanCallMedia(address, FreePort(firstPort, address), headless: true);
using var decoded = new DecodedVideo(receiver.Receiver); using var decoded = new DecodedVideo(receiver.Receiver);
using var pipe = new AnonymousPipeServerStream(PipeDirection.Out); using var pipe = new AnonymousPipeServerStream(PipeDirection.Out);
await using var source = File.OpenRead(Environment.GetEnvironmentVariable("POSTIT_TEST_TS")!); 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(false, false)]
[InlineData(true, false)] [InlineData(true, false)]
[InlineData(false, true)] [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 ct = TestContext.Current.CancellationToken;
var firstPort = FreePort(); var firstPort = FreePort(address: address);
var secondPort = FreePort(firstPort); var secondPort = FreePort(firstPort, address);
await using var first = new LibVlcLanCallMedia("127.0.0.1", firstPort, headless: true); await using var first = new LibVlcLanCallMedia(address, firstPort, headless: true);
await using var second = new LibVlcLanCallMedia("127.0.0.1", secondPort, headless: true); await using var second = new LibVlcLanCallMedia(address, secondPort, headless: true);
using var firstVideo = new DecodedVideo(first.Receiver); using var firstVideo = new DecodedVideo(first.Receiver);
using var secondVideo = new DecodedVideo(second.Receiver); using var secondVideo = new DecodedVideo(second.Receiver);
await first.StartReceivingAsync(ct); await first.StartReceivingAsync(ct);
@ -142,17 +152,18 @@ public sealed class LinuxLanCallMediaTests(ITestOutputHelper output)
private static bool HasMedia(LibVlcLanCallMedia peer, DecodedVideo video) => private static bool HasMedia(LibVlcLanCallMedia peer, DecodedVideo video) =>
peer.Statistics.DecodedAudio > 0 && video.AudioSamples > 4800 && video.Frames >= 10; 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) for (var port = 32000; port < 60000; port += 2)
{ {
if (port == except) continue; if (port == except) continue;
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); using var socket = new Socket(ip.AddressFamily, SocketType.Dgram, ProtocolType.Udp);
using var rtcp = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); using var rtcp = new Socket(ip.AddressFamily, SocketType.Dgram, ProtocolType.Udp);
try try
{ {
socket.Bind(new IPEndPoint(IPAddress.Loopback, port)); socket.Bind(new IPEndPoint(ip, port));
rtcp.Bind(new IPEndPoint(IPAddress.Loopback, port + 1)); rtcp.Bind(new IPEndPoint(ip, port + 1));
return port; return port;
} }
catch (SocketException ex) when (ex.SocketErrorCode == SocketError.AddressAlreadyInUse) { } 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)), Endpoint = new(localAddress, port, Convert.ToHexString(RandomNumberGenerator.GetBytes(16)),
Convert.ToHexString(RandomNumberGenerator.GetBytes(14))); Convert.ToHexString(RandomNumberGenerator.GetBytes(14)));
if (!Endpoint.IsValid()) if (!Endpoint.IsValid())
throw new ArgumentException("Adresse IPv4 unicast et port UDP pair (1024-65534) requis."); throw new ArgumentException("Adresse IPv4/IPv6 unicast et port UDP pair (1024-65534) requis.");
using (var reservation = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp)) var localIp = IPAddress.Parse(localAddress);
using (var rtcp = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp)) 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)); reservation.Bind(new IPEndPoint(IPAddress.Parse(localAddress), port));
rtcp.Bind(new IPEndPoint(IPAddress.Parse(localAddress), port + 1)); 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"); : new LibVLC("--avcodec-hw=none", "--intf=dummy");
try 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(":network-caching=150");
_receiverMedia.AddOption(":rtcp-port=" + (port + 1)); _receiverMedia.AddOption(":rtcp-port=" + (port + 1));
_receiverMedia.AddOption(":srtp-key=" + Endpoint.Key); _receiverMedia.AddOption(":srtp-key=" + Endpoint.Key);

View file

@ -116,7 +116,7 @@ public partial class LanCallViewModel : ObservableObject, IAsyncDisposable
foreach (var address in NetworkInterface.GetAllNetworkInterfaces() foreach (var address in NetworkInterface.GetAllNetworkInterfaces()
.Where(n => n.OperationalStatus == OperationalStatus.Up) .Where(n => n.OperationalStatus == OperationalStatus.Up)
.SelectMany(n => n.GetIPProperties().UnicastAddresses) .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)))) .Distinct().OrderBy(a => IPAddress.IsLoopback(IPAddress.Parse(a))))
Addresses.Add(address); Addresses.Add(address);
Address = preferred == null ? Addresses.FirstOrDefault() : Addresses.FirstOrDefault(a => a == preferred); 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." Status = Cameras.Count == 0 ? "Aucune camera detectee. Verifiez les peripheriques puis actualisez."
: Incoming && _call != null : Incoming && _call != null
? $"{_call.Caller} propose un appel dans {_call.RoomName}. Selectionnez vos peripheriques puis acceptez ou refusez." ? $"{_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) if (_settings != null)
{ {
_settings.PodcastCameras = Cameras.ToArray(); _settings.PodcastCameras = Cameras.ToArray();
@ -200,10 +200,10 @@ public partial class LanCallViewModel : ObservableObject, IAsyncDisposable
private void ValidateSelection() private void ValidateSelection()
{ {
if (!AllowAddressDisclosure) 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) if (Camera == null || Microphone == null)
throw new InvalidOperationException("Une camera et un microphone sont requis."); 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) !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)."); throw new InvalidOperationException("Selectionnez une adresse locale et un port UDP pair (1024-65534).");
_activeCamera = Camera.Value; _activeCamera = Camera.Value;

View file

@ -103,16 +103,16 @@
Content="Envoyer" /> Content="Envoyer" />
</Grid> </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"> IsEnabled="{Binding IsConnected}" Margin="0,0,0,8">
<ScrollViewer MaxHeight="520"> <ScrollViewer MaxHeight="520">
<StackPanel DataContext="{Binding LanCall}" x:DataType="vm:LanCallViewModel" Spacing="8"> <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" /> TextWrapping="Wrap" />
<WrapPanel IsEnabled="{Binding CanConfigure}" IsVisible="{Binding !RemoteVideo}"> <WrapPanel IsEnabled="{Binding CanConfigure}" IsVisible="{Binding !RemoteVideo}">
<Button Content="Actualiser les peripheriques" Command="{Binding RefreshDevicesCommand}" Margin="0,0,8,4" /> <Button Content="Actualiser les peripheriques" Command="{Binding RefreshDevicesCommand}" Margin="0,0,8,4" />
<ComboBox Width="160" ItemsSource="{Binding Addresses}" SelectedItem="{Binding Address}" <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" /> <TextBox Width="80" Text="{Binding Port}" PlaceholderText="Port pair" Margin="0,0,8,4" />
<ComboBox Width="190" ItemsSource="{Binding Cameras}" SelectedItem="{Binding Camera}" <ComboBox Width="190" ItemsSource="{Binding Cameras}" SelectedItem="{Binding Camera}"
PlaceholderText="Camera" Margin="0,0,8,4"> PlaceholderText="Camera" Margin="0,0,8,4">
@ -132,7 +132,7 @@
</ComboBox> </ComboBox>
<TextBox Width="170" Text="{Binding Target}" PlaceholderText="Pseudo du pair" Margin="0,0,8,4" /> <TextBox Width="170" Text="{Binding Target}" PlaceholderText="Pseudo du pair" Margin="0,0,8,4" />
</WrapPanel> </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}" IsChecked="{Binding AllowAddressDisclosure}" IsEnabled="{Binding CanConfigure}"
IsVisible="{Binding !RemoteVideo}" /> IsVisible="{Binding !RemoteVideo}" />
<WrapPanel> <WrapPanel>

View file

@ -40,13 +40,26 @@ public sealed record LanCallEndpoint(string Address, int Port, string Key, strin
public override string ToString() => "SRTP endpoint (credentials redacted)"; public override string ToString() => "SRTP endpoint (credentials redacted)";
public bool IsValid() => public bool IsValid() =>
LanCallNetwork.IsUnicastIPv4(Address) && Port is >= 1024 and <= 65534 && Port % 2 == 0 && LanCallNetwork.IsUnicastAddress(Address) && Port is >= 1024 and <= 65534 && Port % 2 == 0 &&
Key is { Length: 32 } && Key.All(Uri.IsHexDigit) && Key is { Length: 32 } && Key.All(Uri.IsHexDigit) &&
Salt is { Length: 28 } && Salt.All(Uri.IsHexDigit); Salt is { Length: 28 } && Salt.All(Uri.IsHexDigit);
} }
public static class LanCallNetwork public static class LanCallNetwork
{ {
public static bool IsUnicastAddress(string address)
{
if (IsUnicastIPv4(address)) return true;
if (!System.Net.IPAddress.TryParse(address, out var ip) ||
ip.AddressFamily != System.Net.Sockets.AddressFamily.InterNetworkV6 ||
ip.ToString() != address || ip.ScopeId != 0 || ip.IsIPv4MappedToIPv6)
return false;
var bytes = ip.GetAddressBytes();
return ip.Equals(System.Net.IPAddress.IPv6Loopback)
|| (bytes[0] & 0xe0) == 0x20
|| (bytes[0] & 0xfe) == 0xfc;
}
public static bool IsUnicastIPv4(string address) public static bool IsUnicastIPv4(string address)
{ {
if (!System.Net.IPAddress.TryParse(address, out var ip) || if (!System.Net.IPAddress.TryParse(address, out var ip) ||

View file

@ -41,6 +41,8 @@ public sealed class CallLanSignalingTests
[Theory] [Theory]
[InlineData("192.168.1.2")] [InlineData("192.168.1.2")]
[InlineData("8.8.8.8")] [InlineData("8.8.8.8")]
[InlineData("2001:db8::1")]
[InlineData("fd00::1")]
public async Task Endpoints_are_exchanged_only_once_between_consented_devices_without_ice_credentials(string address) public async Task Endpoints_are_exchanged_only_once_between_consented_devices_without_ice_credentials(string address)
{ {
var hub = new RecordingCallHub(); var hub = new RecordingCallHub();
@ -106,11 +108,24 @@ public sealed class CallLanSignalingTests
[InlineData("255.255.255.255", false)] [InlineData("255.255.255.255", false)]
[InlineData("169.254.1.1", false)] [InlineData("169.254.1.1", false)]
[InlineData("::ffff:8.8.8.8", false)] [InlineData("::ffff:8.8.8.8", false)]
[InlineData("::1", false)] [InlineData("::1", true)]
[InlineData("2001:db8::1", true)]
[InlineData("2606:4700:4700::1111", true)]
[InlineData("fd00::123", true)]
[InlineData("fc00::1", true)]
[InlineData("::", false)]
[InlineData("ff02::1", false)]
[InlineData("fe80::1", false)]
[InlineData("fe80::1%2", false)]
[InlineData("2001:db8::1%2", false)]
[InlineData("fec0::1", false)]
[InlineData("2001:DB8::1", false)]
[InlineData("[2001:db8::1]", false)]
[InlineData("2001:db8::1,port=80", false)]
[InlineData("localhost", false)] [InlineData("localhost", false)]
[InlineData("127.1", false)] [InlineData("127.1", false)]
[InlineData("192.168.1.2,port=80", false)] [InlineData("192.168.1.2,port=80", false)]
public void Only_canonical_unicast_ipv4_endpoints_are_allowed(string address, bool expected) => public void Only_canonical_supported_unicast_endpoints_are_allowed(string address, bool expected) =>
Assert.Equal(expected, Endpoint(address).IsValid()); Assert.Equal(expected, Endpoint(address).IsValid());
[Fact] [Fact]