feat(calls): implement Linux LAN peer calls over SRTP

Reuse existing ChatHub consent and Podcast LibVLC capture for direct bidirectional H.264/AAC media, with embedded call controls and cleanup. Require HTTPS key exchange and reject IP-masking policies for this opt-in LAN/VPN transport.

Add native SRTP, signaling and lifecycle tests; document setup and remaining physical-device validation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Paul Schneider 2026-10-26 03:05:23 +00:00
commit cc28ee8b29
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
20 changed files with 1394 additions and 29 deletions

View file

@ -5,6 +5,7 @@
### Added
* [PostIt.Desktop/API] Add opt-in Linux LAN/VPN peer calls through the existing ChatHub, reusing Podcast LibVLC camera/microphone capture and H.264/AAC encoding with direct SRTP instead of HLS. Add consent, embedded local/remote video, hangup/interruption cleanup, private IPv4 validation and per-direction ephemeral keys over HTTPS-only signaling. Reject IP-masking accounts rather than silently bypassing TURN; keep this transport disabled by default and separate from the future Internet/group-call design. Test real bidirectional encrypted audio/video, wrong-key rejection, preview output, signaling and UI lifecycle; physical camera/two-machine rendering remains to validate.
* [PostIt/API] Begin Android/Linux call integration on the existing authenticated ChatHub: typed invitation, single-device consent, bounded SDP/ICE exchange and hangup; derive relay policy from account preferences and issue temporary coturn credentials. Attach the shared PostIt signaling client to the chat connection, end calls on disconnect/room departure/privacy activation, and add targeted regression tests. Calls remain disabled by default; native media adapters and Android/Linux end-to-end validation are not implemented yet.
* [PostIt] Clarify reuse of the existing ChatHub for call signaling and document the inconclusive Android native WebRTC experiment; discard the isolated laboratory without changing production chat or media behavior.
* [PostIt/API] Persist the account-level `HideIpAddressInCalls` preference (default false), expose it through the authenticated profile API and PostIt profile editor, and preserve it when older clients omit it. Prepare future TURN-only calls without enabling media transport yet.

View file

@ -2,14 +2,14 @@
## Statut et périmètre
Ce document décrit l'architecture cible et l'implémentation commencée :
les appels ne sont pas encore disponibles. La préférence de confidentialité
est persistée et éditable dans le profil. Les premiers contrats de
signalisation sont intégrés au chat, désactivés par défaut ; aucun transport
média natif n'est encore raccordé.
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 signalisation est intégrée au chat, désactivée par défaut. Un premier
transport natif **Desktop Linux vers Desktop Linux sur LAN/VPN** est
raccordé : capture LibVLC existante et SRTP direct, sans libwebrtc ni JNI
Android. Il ne réalise pas encore la cible Internet, TURN ou groupe.
Il complète la [topologie de PostIt](postit.md) et les
[mécanismes de notification existants](notifications.md).
œ
La cible couvre :
- les appels audio-vidéo bidirectionnels entre deux utilisateurs ;
@ -18,8 +18,131 @@ La cible couvre :
- les appels de groupe ;
- les clients Browser, Android et Desktop.
## Première implémentation Desktop Linux : SRTP sur LAN/VPN
Cette tranche réutilise la capture caméra/microphone et les encodeurs des
Podcasts, mais **pas leur segmentation HLS ni leurs envois de fichiers** :
```text
PostIt Linux A ----- ChatHub authentifié / HTTPS ----- PostIt Linux B
invitation, consentement,
adresses et clés, fin
PostIt Linux A ===== H.264/AAC, MPEG-TS sur SRTP/UDP ===== PostIt Linux B
dans les deux sens
```
L'implémentation est dans
[LinuxLanCallMedia](../../src/PostIt/PostIt/Services/LinuxLanCallMedia.cs),
[LinuxLanCallVideo](../../src/PostIt/PostIt/Services/LinuxLanCallVideo.cs) et
[LanCallViewModel](../../src/PostIt/PostIt/ViewModels/Chat/LanCallViewModel.cs).
Les contrôles et les deux surfaces vidéo sont intégrés à la page du chat.
Il n'y a pas de service de salles supplémentaire, d'enregistrement,
de fichier média temporaire ou de moteur WebRTC Android ajouté.
### Activer et essayer
1. Sur le serveur, activer explicitement les deux options :
```json
{
"AudioVideoCalls": {
"Enabled": true,
"EnableLanCalls": true
}
}
```
Les deux valeurs sont `false` par défaut. Les paramètres TURN ne sont
pas nécessaires pour ce mode. Utiliser le ChatHub en **HTTPS** ;
invitation, acceptation et échange de clés LAN sont refusés en HTTP.
Derrière un proxy TLS, configurer correctement le schéma HTTPS transmis
à ASP.NET Core, uniquement depuis les proxys de confiance.
2. Utiliser deux Desktop Linux avec LibVLC et ses modules V4L2, PulseAudio,
x264, AAC et RTP/SRTP installés. La version Debian 13 a été utilisée
pour les essais natifs. Les deux comptes doivent être présents dans
la même salle existante et ne pas se bloquer mutuellement.
3. Dans « Appel Linux direct (LAN/VPN) », actualiser les périphériques,
sélectionner la caméra, le microphone et une IPv4 privée locale
accessible depuis le pair. Les adresses RFC1918 et de bouclage sont
acceptées ; pas de DNS, IPv6 ou adresse publique. Le bouclage sert aux
essais sur un même hôte, pas à joindre un second poste.
4. Choisir un port UDP **pair entre 1024 et 65534**, par défaut 5004.
Autoriser l'entrée UDP sur ce port depuis le pair dans le pare-feu.
RTP/RTCP partagent ce port. Deux clients sur la même adresse doivent
utiliser des ports distincts. Aucun pare-feu n'est modifié automatiquement.
5. Saisir le pseudo exact du destinataire, confirmer la divulgation de
l'adresse LAN/VPN, puis appeler. Le destinataire choisit ses propres
périphériques et son adresse, confirme et accepte explicitement.
Aucun accès à la caméra/microphone ne commence avant l'acceptation ;
l'émission attend également la destination validée du pair.
6. Utiliser un **casque** : il n'y a pas d'annulation d'écho acoustique.
« Refuser / Raccrocher », la sortie de page et la perte de SignalR
arrêtent et libèrent les médias. Il n'y a pas de reprise automatique
de capture après reconnexion.
### Confidentialité et limites
- Une clé SRTP aléatoire de 128 bits et un sel de 112 bits sont générés
par appel et par direction avec le générateur cryptographique système.
Seuls les deux appareils ayant accepté échangent un endpoint chacun
via SignalR authentifié. Les représentations diagnostiques masquent
adresse, clé et sel.
- Le chiffrement SRTP est fourni par LibVLC ; les clés passent par
le serveur de signalisation, qui **peut donc les connaître**. Ce n'est
pas un chiffrement de bout en bout à l'insu du serveur.
- Si l'un des comptes impose `HideIpAddressInCalls`, le mode LAN est
**refusé**, y compris si la préférence change avant l'acceptation.
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,
d'interopérabilité Browser/Android, de négociation de codecs ni
d'adaptation de débit. Ce mode exige un LAN/VPN routable entre les pairs.
- 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.
Le retour local n'émet pas le son du microphone.
- L'absence de première réception audio **et** vidéo provoque l'arrêt
après 30 secondes ; les erreurs natives sont affichées et terminent
l'appel. Après réception initiale, les compteurs LibVLC ne permettent
pas ici de diagnostiquer précisément une panne média silencieuse :
l'utilisateur peut raccrocher, et la perte de SignalR arrête toujours
la capture. Pas encore de mesure de qualité ou de latence affichée.
### Validation effectuée et restante
Les tests natifs utilisent deux moteurs LibVLC réels, un fichier audio-vidéo
synthétique fourni explicitement et de vrais sockets UDP de bouclage :
- décodage bidirectionnel de trames vidéo et d'échantillons audio PCM ;
- refus de décodage avec une mauvaise clé, sans casser l'autre direction ;
- même échange avec la branche de prévisualisation locale activée
(sortie factice dans ce test sans fenêtre).
Les tests de signalisation couvrent consentement, sélection d'un seul
appareil, confidentialité, HTTPS, validation des endpoints et arrêt.
Les tests Avalonia sans fenêtre couvrent l'ordre invitation/acceptation/
capture, le refus, les événements tardifs, l'interruption, les erreurs média
et l'annulation d'un démarrage natif en attente.
Pour exécuter les tests média, définir `POSTIT_TEST_MEDIA` avec le chemin
d'un MP4 H.264/AAC de test (640 × 480), puis :
```sh
dotnet test --project src/PostIt/PostIt.Tests/PostIt.Tests.csproj \
--filter-class '*LinuxLanCallMediaTests'
```
Sans cette entrée explicite, ces tests sont ignorés, pas remplacés par un
succès fictif. Les tests de contrôle s'exécutent sans caméra.
**La caméra physique, le rendu natif visible dans deux applications, le
son audible et un appel entre deux machines restent à valider** : aucune
caméra n'était disponible sur l'hôte de développement. Les essais de
bouclage ne prouvent ni ces points ni une latence conversationnelle.
## Choix des protocoles
La table suivante demeure la **cible multi-plateforme initiale**, distincte
du premier transport Linux LAN/VPN décrit ci-dessus.
| Besoin | Choix | Rôle |
|---|---|---|
| Médias temps réel | WebRTC | Audio-vidéo bidirectionnel à faible latence, adaptation au réseau |
@ -298,14 +421,13 @@ propose pas encore de bouton d'appel ; une éventuelle invitation est
signalée comme non utilisable faute de moteur natif, sans acceptation
automatique.
### Travail natif restant avant activation
### Travail restant pour la cible Internet multi-plateforme
- Android : intégrer un adaptateur libwebrtc dans l'application existante,
isoler le problème JNI observé, vérifier les versions Java/natives et
conserver les diagnostics fatals utiles, sans journaliser SDP ou secrets.
- Desktop Linux : intégrer un moteur WebRTC natif dans PostIt.Desktop
avec capture réelle et rendu Avalonia ; un processus Python de test
GStreamer ne vaut pas cette intégration.
- Android : l'approche Java/JNI libwebrtc a été écartée à la demande du
projet ; aucun adaptateur expérimental n'est conservé. Le transport
compatible avec la capture Podcast et la cible Internet reste à choisir.
- Desktop Linux : le transport SRTP LAN est raccordé, mais ne remplace pas
les validations matérielles ni une solution de connectivité Internet.
- Côté partagé : relier consentement, états réels du moteur, mise en
sourdine, arrêt et libération des ressources à la signalisation.
- Valider un appel Android ↔ Desktop Linux avec caméra avant et microphone

View file

@ -15,6 +15,7 @@ public sealed class CallSignalingClientTests
var id = Guid.NewGuid();
var ct = TestContext.Current.CancellationToken;
await Assert.ThrowsAsync<InvalidOperationException>(() => client.InviteAsync("General", "bob", ct));
await Assert.ThrowsAsync<InvalidOperationException>(() => client.InviteLanAsync("General", "bob", ct));
await Assert.ThrowsAsync<InvalidOperationException>(() => client.AcceptAsync(id, ct));
await Assert.ThrowsAsync<InvalidOperationException>(() => client.EndAsync(id, ct));
await Assert.ThrowsAsync<InvalidOperationException>(() => client.SendAsync(id, new("offer", "sdp"), ct));
@ -50,5 +51,10 @@ public sealed class CallSignalingClientTests
var signal = new CallSignal("candidate", Candidate: "candidate:1 1 udp 100 192.0.2.1 5000 typ relay",
SdpMid: "video", SdpMLineIndex: 1);
Assert.Equal(signal, JsonSerializer.Deserialize<CallSignal>(JsonSerializer.Serialize(signal, options), options));
var lan = call with { RelayOnly = false, Transport = CallTransports.LanSrtp };
Assert.Equal(lan, JsonSerializer.Deserialize<CallSession>(JsonSerializer.Serialize(lan, options), options));
var endpoint = new CallSignal("rtp-endpoint",
Endpoint: new("192.168.1.2", 5004, new string('a', 32), new string('b', 28)));
Assert.Equal(endpoint, JsonSerializer.Deserialize<CallSignal>(JsonSerializer.Serialize(endpoint, options), options));
}
}

View file

@ -0,0 +1,192 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using PostIt.Services;
using PostIt.ViewModels.Chat;
using Yavsc.Abstract.Chat.Calls;
namespace PostIt.Tests;
public sealed class LanCallViewModelTests
{
private static CallSession Session() => new(Guid.NewGuid(), "General", "alice", "bob", false,
DateTimeOffset.UtcNow.AddMinutes(1), CallTransports.LanSrtp);
private static LanCallViewModel Configure(Signaling signaling, Func<string, int, ILanCallMedia> create)
{
var vm = new LanCallViewModel(signaling, create)
{
Address = "127.0.0.1", Camera = new("Camera", "/dev/video0"),
Microphone = new("Micro", "default"), AllowAddressDisclosure = true,
Room = "General", Target = "bob"
};
vm.Addresses.Add("127.0.0.1");
return vm;
}
[AvaloniaFact]
public async Task No_capture_starts_before_explicit_acceptance_and_endpoint_exchange()
{
var signaling = new Signaling();
var media = new FakeMedia();
var created = 0;
await using var vm = Configure(signaling, (_, _) => { created++; return media; });
signaling.Invite(vm, signaling.Call);
await WaitAsync(() => vm.Incoming);
Assert.Equal(0, created);
Assert.Empty(signaling.Sent);
await vm.AcceptCommand.ExecuteAsync(null);
await WaitAsync(() => vm.LocalVideo != null);
Assert.Equal(1, created);
Assert.False(media.Sending);
Assert.Single(signaling.Sent);
Assert.Equal("rtp-endpoint", signaling.Sent[0].Kind);
signaling.RemoteEndpoint(signaling.Call.Id);
await WaitAsync(() => media.Sending);
Assert.Same(media.LocalVideo, vm.LocalVideo);
Assert.Same(media.RemoteVideo, vm.RemoteVideo);
await vm.HangupAsync();
Assert.True(media.Disposed);
Assert.False(vm.Busy);
Assert.Null(vm.LocalVideo);
Assert.Contains(signaling.Call.Id, signaling.EndedCalls);
}
[AvaloniaFact]
public async Task Caller_waits_for_consent_and_interruption_releases_media_without_reconnecting()
{
var signaling = new Signaling();
var media = new FakeMedia();
var created = 0;
await using var vm = Configure(signaling, (_, _) => { created++; return media; });
await vm.InviteCommand.ExecuteAsync(null);
Assert.True(vm.Busy);
Assert.Equal(0, created);
signaling.Ready();
await WaitAsync(() => vm.LocalVideo != null);
signaling.RemoteEndpoint(signaling.Call.Id);
await WaitAsync(() => media.Sending);
signaling.Disconnect();
await WaitAsync(() => media.Disposed);
Assert.False(vm.Busy);
Assert.Null(vm.RemoteVideo);
Assert.Contains("interrompue", vm.Status);
}
[AvaloniaFact]
public async Task Missing_disclosure_consent_or_devices_blocks_the_invitation()
{
var signaling = new Signaling();
await using var vm = Configure(signaling, (_, _) => throw new Exception("Must not capture"));
vm.AllowAddressDisclosure = false;
await vm.InviteCommand.ExecuteAsync(null);
Assert.Equal(0, signaling.Invites);
Assert.Contains("Confirmez", vm.Status);
vm.AllowAddressDisclosure = true;
vm.Camera = null;
await vm.InviteCommand.ExecuteAsync(null);
Assert.Equal(0, signaling.Invites);
Assert.Contains("camera", vm.Status);
Assert.False(vm.Busy);
}
[AvaloniaFact]
public async Task Refusal_never_creates_media_and_late_signals_cannot_restart_capture()
{
var signaling = new Signaling();
var created = 0;
await using var vm = Configure(signaling, (_, _) => { created++; return new FakeMedia(); });
signaling.Invite(vm, signaling.Call);
await WaitAsync(() => vm.Incoming);
await vm.HangupAsync();
signaling.Ready();
signaling.RemoteEndpoint(signaling.Call.Id);
await Task.Delay(30, TestContext.Current.CancellationToken);
Assert.Equal(0, created);
Assert.False(vm.Busy);
}
[AvaloniaFact]
public async Task Hangup_cancels_pending_native_start_without_waiting_for_startup_timeout()
{
var signaling = new Signaling();
var media = new FakeMedia { DelayStartup = true };
await using var vm = Configure(signaling, (_, _) => media);
await vm.InviteCommand.ExecuteAsync(null);
signaling.Ready();
await WaitAsync(() => vm.LocalVideo != null);
signaling.RemoteEndpoint(signaling.Call.Id);
await WaitAsync(() => media.Sending);
await vm.HangupAsync().WaitAsync(TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken);
Assert.True(media.Disposed);
Assert.False(vm.Busy);
Assert.Contains(signaling.Call.Id, signaling.EndedCalls);
}
[AvaloniaFact]
public async Task Media_failure_ends_the_call_and_disposes_capture()
{
var signaling = new Signaling();
var media = new FakeMedia();
await using var vm = Configure(signaling, (_, _) => media);
await vm.InviteCommand.ExecuteAsync(null);
signaling.Ready();
await WaitAsync(() => vm.LocalVideo != null);
media.Fail();
await WaitAsync(() => media.Disposed);
Assert.False(vm.Busy);
Assert.Contains("test media failure", vm.Status);
Assert.Contains(signaling.Call.Id, signaling.EndedCalls);
}
private static async Task WaitAsync(Func<bool> condition)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(5));
while (!condition()) await Task.Delay(10, timeout.Token);
}
private sealed class FakeMedia : ILanCallMedia
{
private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Control LocalVideo { get; } = new Border();
public Control RemoteVideo { get; } = new Border();
public LanCallEndpoint Endpoint { get; } = new("127.0.0.1", 5004, new string('a', 32), new string('b', 28));
public Task Completion => _completion.Task;
public bool HasRemoteMedia => false;
public bool Sending { get; private set; }
public bool Disposed { get; private set; }
public bool DelayStartup { get; init; }
public async Task StartSendingAsync(LanCallEndpoint remote, string camera, string microphone, CancellationToken ct)
{
Sending = true;
if (DelayStartup) await Task.Delay(Timeout.Infinite, ct);
}
public void Fail() => _completion.TrySetException(new IOException("test media failure"));
public ValueTask DisposeAsync() { Disposed = true; _completion.TrySetResult(); return ValueTask.CompletedTask; }
}
private sealed class Signaling : ILanCallSignaling
{
public event Action<CallSession>? Invitation;
public event Action<CallAccepted>? Accepted;
public event Action<CallEnded>? Ended;
public event Action<Guid, CallSignal>? Signal;
public event Action? Interrupted;
public CallSession Call { get; } = Session();
public List<CallSignal> Sent { get; } = [];
public List<Guid> EndedCalls { get; } = [];
public int Invites { get; private set; }
public Task<CallSession> InviteLanAsync(string room, string user, CancellationToken ct = default)
{ Invites++; return Task.FromResult(Call); }
public Task AcceptAsync(Guid id, CancellationToken ct = default) { Ready(); return Task.CompletedTask; }
public Task EndAsync(Guid id, CancellationToken ct = default)
{ EndedCalls.Add(id); Ended?.Invoke(new(id, "hangup")); return Task.CompletedTask; }
public Task SendAsync(Guid id, CallSignal signal, CancellationToken ct = default)
{ Sent.Add(signal); return Task.CompletedTask; }
public void Invite(LanCallViewModel _, CallSession call) => Invitation?.Invoke(call);
public void Ready() => Accepted?.Invoke(new(Call, []));
public void RemoteEndpoint(Guid id) => Signal?.Invoke(id, new("rtp-endpoint",
Endpoint: new("127.0.0.1", 5006, new string('c', 32), new string('d', 28))));
public void Disconnect() => Interrupted?.Invoke();
}
}

View file

@ -0,0 +1,113 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Runtime.InteropServices;
using LibVLCSharp.Shared;
using PostIt.Services;
namespace PostIt.Tests;
public sealed class LinuxLanCallMediaTests(ITestOutputHelper output)
{
public static bool HasFixture => OperatingSystem.IsLinux() &&
File.Exists(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA"));
[Theory(Skip = "Set POSTIT_TEST_MEDIA to an audio/video file; requires Linux LibVLC SRTP and H.264/AAC encoders.",
SkipUnless = nameof(HasFixture))]
[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)
{
var ct = TestContext.Current.CancellationToken;
var firstPort = FreePort();
var secondPort = FreePort(firstPort);
await using var first = new LinuxLanCallMedia("127.0.0.1", firstPort, headless: true);
await using var second = new LinuxLanCallMedia("127.0.0.1", secondPort, headless: true);
using var firstVideo = new DecodedVideo(first.Receiver);
using var secondVideo = new DecodedVideo(second.Receiver);
await first.StartReceivingAsync(ct);
await second.StartReceivingAsync(ct);
await Task.Delay(300, ct);
Media Source(LibVLC vlc)
{
var media = new Media(vlc, new Uri(Path.GetFullPath(Environment.GetEnvironmentVariable("POSTIT_TEST_MEDIA")!)));
media.AddOption(":input-repeat=5");
return media;
}
await first.StartSendingAsync(wrongKey ? second.Endpoint with { Key = new string('0', 32) } : second.Endpoint,
Source, preview, ct);
await second.StartSendingAsync(first.Endpoint, Source, preview, ct);
var elapsed = Stopwatch.StartNew();
while (elapsed.Elapsed < TimeSpan.FromSeconds(wrongKey ? 8 : 20))
{
if (first.Completion.IsCompleted) await first.Completion;
if (second.Completion.IsCompleted) await second.Completion;
if (!wrongKey && HasMedia(first, firstVideo) && HasMedia(second, secondVideo) &&
first.HasRemoteMedia && second.HasRemoteMedia) break;
await Task.Delay(100, ct);
}
var a = first.Statistics;
var b = second.Statistics;
output.WriteLine($"A: audioSamples={firstVideo.AudioSamples}, rendered={firstVideo.Frames}; " +
$"B: audioSamples={secondVideo.AudioSamples}, rendered={secondVideo.Frames}; elapsed={elapsed.ElapsedMilliseconds}ms");
Assert.True(HasMedia(first, firstVideo), "First peer must decode received audio and video.");
Assert.True(first.HasRemoteMedia, "The UI must detect initial audio/video reception.");
if (wrongKey)
{
Assert.Equal(0, b.DecodedVideo);
Assert.Equal(0, b.DecodedAudio);
Assert.Equal(0, secondVideo.Frames);
Assert.Equal(0, secondVideo.AudioSamples);
Assert.False(second.HasRemoteMedia);
}
else
{
Assert.True(HasMedia(second, secondVideo), "Second peer must decode received audio and video.");
Assert.True(second.HasRemoteMedia);
}
}
private static bool HasMedia(LinuxLanCallMedia peer, DecodedVideo video) =>
peer.Statistics.DecodedAudio > 0 && video.AudioSamples > 4800 && video.Frames >= 10;
private static int FreePort(int except = 0)
{
for (var port = 32000; port < 60000; port += 2)
{
if (port == except) continue;
using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
try { socket.Bind(new IPEndPoint(IPAddress.Loopback, port)); return port; }
catch (SocketException ex) when (ex.SocketErrorCode == SocketError.AddressAlreadyInUse) { }
}
throw new IOException("No free RTP port available.");
}
private sealed class DecodedVideo : IDisposable
{
private readonly IntPtr _pixels = Marshal.AllocHGlobal(640 * 480 * 4);
private int _frames;
private long _audioSamples;
public int Frames => Volatile.Read(ref _frames);
public long AudioSamples => Interlocked.Read(ref _audioSamples);
private readonly MediaPlayer _player;
public DecodedVideo(MediaPlayer player)
{
_player = player;
player.SetAudioFormat("S16N", 48000, 1);
player.SetAudioCallbacks((_, _, count, _) => Interlocked.Add(ref _audioSamples, count),
null, null, null, null);
player.SetVideoFormat("RV32", 640, 480, 640 * 4);
player.SetVideoCallbacks((_, planes) =>
{
Marshal.WriteIntPtr(planes, _pixels);
return IntPtr.Zero;
}, null, (_, _) => Interlocked.Increment(ref _frames));
}
public void Dispose()
{
_player.Stop();
Marshal.FreeHGlobal(_pixels);
}
}
}

View file

@ -6,7 +6,7 @@ using Yavsc.Abstract.Chat.Calls;
namespace PostIt.Services;
public sealed class CallSignalingClient : IDisposable
public sealed class CallSignalingClient : ILanCallSignaling, IDisposable
{
private readonly HubConnection _connection;
private readonly IDisposable[] _subscriptions;
@ -39,6 +39,12 @@ public sealed class CallSignalingClient : IDisposable
return _connection.InvokeAsync<CallSession>(CallHubMethods.Invite, room, user, ct);
}
public Task<CallSession> InviteLanAsync(string room, string user, CancellationToken ct = default)
{
EnsureConnected();
return _connection.InvokeAsync<CallSession>(CallHubMethods.InviteLan, room, user, ct);
}
public Task AcceptAsync(Guid callId, CancellationToken ct = default)
{
EnsureConnected();

View file

@ -0,0 +1,19 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Yavsc.Abstract.Chat.Calls;
namespace PostIt.Services;
public interface ILanCallSignaling
{
event Action<CallSession>? Invitation;
event Action<CallAccepted>? Accepted;
event Action<CallEnded>? Ended;
event Action<Guid, CallSignal>? Signal;
event Action? Interrupted;
Task<CallSession> InviteLanAsync(string room, string user, CancellationToken ct = default);
Task AcceptAsync(Guid callId, CancellationToken ct = default);
Task EndAsync(Guid callId, CancellationToken ct = default);
Task SendAsync(Guid callId, CallSignal signal, CancellationToken ct = default);
}

View file

@ -0,0 +1,179 @@
using System;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using LibVLCSharp.Shared;
using Yavsc.Abstract.Chat.Calls;
namespace PostIt.Services;
public sealed class LinuxLanCallMedia : IAsyncDisposable
{
private readonly LibVLC _senderVlc;
private readonly LibVLC _receiverVlc;
private readonly Media _receiverMedia;
private Media? _senderMedia;
private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly SemaphoreSlim _gate = new(1);
private bool _receiving;
private bool _disposed;
private Task? _stop;
public LanCallEndpoint Endpoint { get; }
public MediaPlayer Sender { get; }
public MediaPlayer Receiver { get; }
public Task Completion => _completion.Task;
public MediaStats Statistics => _receiverMedia.Statistics;
public bool HasRemoteMedia => !_disposed && Statistics.DecodedAudio > 0 && Statistics.DecodedVideo > 0;
public LinuxLanCallMedia(string localAddress, int port, bool headless = false)
{
if (!OperatingSystem.IsLinux())
throw new PlatformNotSupportedException("Les appels SRTP LAN sont disponibles sur Desktop Linux.");
Endpoint = new(localAddress, port, Convert.ToHexString(RandomNumberGenerator.GetBytes(16)),
Convert.ToHexString(RandomNumberGenerator.GetBytes(14)));
if (!Endpoint.IsValid())
throw new ArgumentException("Adresse IPv4 privee et port UDP pair (1024-65534) requis.");
using (var reservation = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp))
reservation.Bind(new IPEndPoint(IPAddress.Parse(localAddress), port));
Core.Initialize();
_senderVlc = headless ? CameraStreamer.CreateHeadlessLibVlc()
: new LibVLC("--avcodec-hw=none", "--aout=dummy", "--intf=dummy");
try
{
_receiverVlc = headless ? CameraStreamer.CreateHeadlessLibVlc()
: new LibVLC("--avcodec-hw=none", "--intf=dummy");
try
{
_receiverMedia = new Media(_receiverVlc, $"rtp://@{localAddress}:{port}", FromType.FromLocation);
_receiverMedia.AddOption(":network-caching=150");
_receiverMedia.AddOption(":rtcp-port=0");
_receiverMedia.AddOption(":srtp-key=" + Endpoint.Key);
_receiverMedia.AddOption(":srtp-salt=" + Endpoint.Salt);
Receiver = new MediaPlayer(_receiverVlc);
Sender = new MediaPlayer(_senderVlc);
Sender.EncounteredError += OnSenderError;
Receiver.EncounteredError += OnReceiverError;
Sender.EndReached += OnSenderEnded;
}
catch
{
_receiverMedia?.Dispose();
Receiver?.Dispose();
Sender?.Dispose();
_receiverVlc.Dispose();
throw;
}
}
catch
{
_senderVlc.Dispose();
throw;
}
}
public async Task StartReceivingAsync(CancellationToken ct)
{
await _gate.WaitAsync(ct);
try
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_stop != null) throw new InvalidOperationException("Cet appel est arrete.");
if (_receiving) return;
ct.ThrowIfCancellationRequested();
if (!Receiver.Play(_receiverMedia))
throw new InvalidOperationException("Reception SRTP impossible. Verifiez adresse et port UDP.");
_receiving = true;
}
finally { _gate.Release(); }
}
public Task StartSendingAsync(LanCallEndpoint remote, string camera, string microphone, CancellationToken ct) =>
StartSendingAsync(remote, vlc => new CameraStreamer(vlc).CreateCaptureMedia(camera, microphone), true, ct);
internal async Task StartSendingAsync(LanCallEndpoint remote, Func<LibVLC, Media> source,
bool preview, CancellationToken ct)
{
if (!remote.IsValid())
throw new ArgumentException("Destination SRTP LAN invalide.", nameof(remote));
if (remote.Address == Endpoint.Address && remote.Port == Endpoint.Port)
throw new ArgumentException("Les deux pairs doivent avoir des ports distincts sur une meme adresse.");
await _gate.WaitAsync(ct);
try
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_stop != null || _senderMedia != null)
throw new InvalidOperationException("La capture de cet appel est deja demarree ou arretee.");
_senderMedia = source(_senderVlc);
ConfigureOutput(_senderMedia, remote, preview);
ct.ThrowIfCancellationRequested();
if (!Sender.Play(_senderMedia))
throw new InvalidOperationException("Capture SRTP impossible. Verifiez camera, microphone et encodeurs H.264/AAC.");
}
finally { _gate.Release(); }
}
internal static void ConfigureOutput(Media media, LanCallEndpoint remote, bool preview)
{
if (!remote.IsValid()) throw new ArgumentException("Destination SRTP invalide.");
// Same capture/encoders as Podcasts, but no HLS files, segmentation or upload.
var encoding = "transcode{vcodec=h264,venc=x264{preset=ultrafast,tune=zerolatency,keyint=30,bframes=0},"
+ "vb=1200,fps=30,acodec=mp4a,ab=96,channels=1,samplerate=48000}:"
+ $"rtp{{dst={remote.Address},port={remote.Port},mux=ts,rtcp-mux,"
+ $"key={remote.Key},salt={remote.Salt},caching=50}}";
media.AddOption(":sout=#" + (preview
? $"duplicate{{dst=display{{noaudio}},dst=\"{encoding}\"}}" : encoding));
}
private void OnSenderError(object? sender, EventArgs args) => _completion.TrySetException(
new InvalidOperationException("Capture/encodage SRTP en echec. Verifiez les peripheriques et encodeurs."));
private void OnReceiverError(object? sender, EventArgs args) => _completion.TrySetException(
new InvalidOperationException("Reception/decodage SRTP en echec. Verifiez le reseau LAN/VPN et les ports UDP."));
private void OnSenderEnded(object? sender, EventArgs args) => _completion.TrySetException(
new InvalidOperationException("La source audio-video s'est terminee."));
private Task StopCoreAsync() => _stop ??= Task.Run(() =>
{
try { Sender.Stop(); }
finally { Receiver.Stop(); }
});
internal async Task StopAsync()
{
await _gate.WaitAsync();
try
{
if (!_disposed) await StopCoreAsync();
}
finally { _gate.Release(); }
}
public async ValueTask DisposeAsync()
{
await _gate.WaitAsync();
try
{
if (_disposed) return;
_disposed = true;
Sender.EncounteredError -= OnSenderError;
Receiver.EncounteredError -= OnReceiverError;
Sender.EndReached -= OnSenderEnded;
try { await StopCoreAsync(); }
finally
{
Sender.Dispose();
Receiver.Dispose();
_senderMedia?.Dispose();
_receiverMedia.Dispose();
_senderVlc.Dispose();
_receiverVlc.Dispose();
_completion.TrySetResult();
}
}
finally { _gate.Release(); }
}
}

View file

@ -0,0 +1,115 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Threading;
using LibVLCSharp.Avalonia;
using Yavsc.Abstract.Chat.Calls;
namespace PostIt.Services;
public interface ILanCallMedia : IAsyncDisposable
{
Control LocalVideo { get; }
Control RemoteVideo { get; }
LanCallEndpoint Endpoint { get; }
Task Completion { get; }
bool HasRemoteMedia { get; }
Task StartSendingAsync(LanCallEndpoint remote, string camera, string microphone, CancellationToken ct);
}
public sealed class LinuxLanCallVideo : ILanCallMedia
{
private readonly LinuxLanCallMedia _media;
private readonly VideoView _local;
private readonly VideoView _remote;
private readonly TaskCompletionSource _localReady = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly CancellationTokenSource _lifetime = new();
private bool _receiverScheduled;
private bool _localScheduled;
private bool _disposed;
private readonly TaskCompletionSource _failure = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Control LocalVideo => _local;
public Control RemoteVideo => _remote;
public LanCallEndpoint Endpoint => _media.Endpoint;
public Task Completion { get; }
public bool HasRemoteMedia => !_disposed && _media.HasRemoteMedia;
public LinuxLanCallVideo(string localAddress, int port)
{
_media = new LinuxLanCallMedia(localAddress, port);
_local = new VideoView { MediaPlayer = _media.Sender, MinHeight = 140 };
_remote = new VideoView { MediaPlayer = _media.Receiver, MinHeight = 260 };
_local.LayoutUpdated += OnLocalLayout;
_remote.LayoutUpdated += OnRemoteLayout;
Completion = ObserveCompletionAsync();
}
private async Task ObserveCompletionAsync()
{
var completed = await Task.WhenAny(_media.Completion, _failure.Task);
await completed;
}
private void OnLocalLayout(object? sender, EventArgs e)
{
if (_localScheduled || _disposed || !_local.IsEffectivelyVisible ||
_local.Bounds.Width <= 0 || _local.Bounds.Height <= 0) return;
_localScheduled = true;
Dispatcher.UIThread.Post(() =>
{
if (_disposed) return;
_local.TryUpdateNativeControlPosition();
_localReady.TrySetResult();
}, DispatcherPriority.Loaded);
}
private void OnRemoteLayout(object? sender, EventArgs e)
{
if (_receiverScheduled || _disposed || !_remote.IsEffectivelyVisible ||
_remote.Bounds.Width <= 0 || _remote.Bounds.Height <= 0) return;
_receiverScheduled = true;
Dispatcher.UIThread.Post(StartReceiver, DispatcherPriority.Loaded);
}
private async void StartReceiver()
{
if (_disposed) return;
try
{
_remote.TryUpdateNativeControlPosition();
await _media.StartReceivingAsync(_lifetime.Token);
}
catch (OperationCanceledException) when (_lifetime.IsCancellationRequested) { }
catch (Exception error) { _failure.TrySetException(error); }
}
public async Task StartSendingAsync(LanCallEndpoint remote, string camera, string microphone, CancellationToken ct)
{
await _localReady.Task.WaitAsync(TimeSpan.FromSeconds(10), ct);
await _media.StartSendingAsync(remote, camera, microphone, ct);
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
_lifetime.Cancel();
_localReady.TrySetCanceled();
_local.LayoutUpdated -= OnLocalLayout;
_remote.LayoutUpdated -= OnRemoteLayout;
try { await _media.StopAsync(); }
finally
{
_local.MediaPlayer = null;
_remote.MediaPlayer = null;
try { await _media.DisposeAsync(); }
finally
{
_lifetime.Dispose();
_failure.TrySetResult();
}
}
}
}

View file

@ -39,6 +39,11 @@ namespace PostIt.ViewModels.Chat
public CallSignalingClient? Calls { get; private set; }
public bool SupportsLanCalls => OperatingSystem.IsLinux() && !OperatingSystem.IsAndroid();
[ObservableProperty]
public partial LanCallViewModel? LanCall { get; set; }
[ObservableProperty]
public partial string CurrentRoom { get; set; } = "General";
@ -83,6 +88,7 @@ namespace PostIt.ViewModels.Chat
partial void OnSelectedRoomChanged(string? value)
{
if (LanCall != null) LanCall.Room = value ?? "";
FocusedMessages = value != null && _messagesByRoom.TryGetValue(value, out var msgs)
? msgs : _systemMessages;
FocusedUsers = value != null && _usersByRoom.TryGetValue(value, out var users)
@ -132,6 +138,13 @@ namespace PostIt.ViewModels.Chat
private async Task DisconnectAsync()
{
Exception? callError = null;
if (LanCall != null)
{
try { await LanCall.DisposeAsync(); }
catch (Exception error) { callError = error; }
finally { LanCall = null; }
}
Calls?.Dispose();
Calls = null;
if (_connection != null)
@ -146,7 +159,9 @@ namespace PostIt.ViewModels.Chat
_messagesByRoom.Clear();
_usersByRoom.Clear();
_systemMessages.Clear();
Status = new StatusNotice("Déconnecté", StatusSeverity.Info,
Status = new StatusNotice(callError == null ? "Déconnecté" :
"Déconnecté ; erreur de fermeture de l'appel : " + callError.Message,
callError == null ? StatusSeverity.Info : StatusSeverity.Error,
new RelayCheckCommand("Connecter", async () => await ConnectAsync()));
}
@ -156,6 +171,11 @@ namespace PostIt.ViewModels.Chat
{
if (_connection != null)
{
if (LanCall != null)
{
await LanCall.DisposeAsync();
LanCall = null;
}
Calls?.Dispose();
Calls = null;
await _connection.DisposeAsync();
@ -200,8 +220,11 @@ namespace PostIt.ViewModels.Chat
.Build();
Calls = new CallSignalingClient(_connection);
Calls.Invitation += call => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
Log($"Invitation audio-vidéo de {call.Caller} dans {call.RoomName} ; moteur natif non disponible."));
if (SupportsLanCalls)
LanCall = new LanCallViewModel(Calls) { Room = SelectedRoom ?? "" };
else
Calls.Invitation += call => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
Log($"Invitation audio-vidéo de {call.Caller} dans {call.RoomName} ; transport non disponible sur cette plateforme."));
Calls.Ended += call => Avalonia.Threading.Dispatcher.UIThread.Post(() =>
Log($"Appel terminé : {call.Reason}."));

View file

@ -0,0 +1,311 @@
using System;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Net.NetworkInformation;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Services;
using Yavsc.Abstract.Chat.Calls;
namespace PostIt.ViewModels.Chat;
public partial class LanCallViewModel : ObservableObject, IAsyncDisposable
{
private readonly ILanCallSignaling _signaling;
private readonly Func<string, int, ILanCallMedia> _createMedia;
private readonly SemaphoreSlim _events = new(1);
private readonly CancellationTokenSource _lifetime = new();
private CancellationTokenSource? _callLifetime;
private ILanCallMedia? _media;
private CallSession? _call;
private bool _disposed;
private bool _accepting;
private bool _endpointReceived;
private string? _activeCamera;
private string? _activeMicrophone;
private string? _activeAddress;
private int _activePort;
public ObservableCollection<string> Addresses { get; } = [];
public ObservableCollection<CaptureDevice> Cameras { get; } = [];
public ObservableCollection<CaptureDevice> Microphones { get; } = [];
[ObservableProperty] public partial string? Address { get; set; }
[ObservableProperty] public partial string Port { get; set; } = "5004";
[ObservableProperty] public partial CaptureDevice? Camera { get; set; }
[ObservableProperty] public partial CaptureDevice? Microphone { get; set; }
[ObservableProperty] public partial bool AllowAddressDisclosure { get; set; }
[ObservableProperty] public partial string Target { get; set; } = "";
[ObservableProperty] public partial string Room { get; set; } = "";
[ObservableProperty] public partial string Status { get; set; } = "Appel Linux LAN/VPN : aucun appel.";
[ObservableProperty] public partial bool Incoming { get; set; }
[ObservableProperty] public partial bool Busy { get; set; }
[ObservableProperty] public partial bool Preparing { get; set; }
[ObservableProperty] public partial Control? LocalVideo { get; set; }
[ObservableProperty] public partial Control? RemoteVideo { get; set; }
public LanCallViewModel(ILanCallSignaling signaling, Func<string, int, ILanCallMedia>? createMedia = null)
{
_signaling = signaling;
_createMedia = createMedia ?? ((address, port) => new LinuxLanCallVideo(address, port));
signaling.Invitation += OnInvitation;
signaling.Accepted += OnAccepted;
signaling.Signal += OnSignal;
signaling.Ended += OnEnded;
signaling.Interrupted += OnInterrupted;
}
[RelayCommand]
private void RefreshDevices()
{
try
{
if (Busy) throw new InvalidOperationException("Terminez l'appel avant de changer les peripheriques.");
Addresses.Clear();
foreach (var address in NetworkInterface.GetAllNetworkInterfaces()
.Where(n => n.OperationalStatus == OperationalStatus.Up)
.SelectMany(n => n.GetIPProperties().UnicastAddresses)
.Select(a => a.Address.ToString()).Where(LanCallNetwork.IsPrivateIPv4)
.Distinct().OrderBy(a => IPAddress.IsLoopback(IPAddress.Parse(a))))
Addresses.Add(address);
Address = Addresses.FirstOrDefault();
Cameras.Clear();
Microphones.Clear();
LibVLCSharp.Shared.Core.Initialize();
using var vlc = CameraStreamer.CreatePreferredLibVlc();
var streamer = new CameraStreamer(vlc);
foreach (var device in streamer.GetCaptureDevices()) Cameras.Add(new(device.Key, device.Value));
foreach (var device in streamer.GetAudioCaptureDevices()) Microphones.Add(new(device.Key, device.Value));
Camera = Cameras.FirstOrDefault();
Microphone = Microphones.FirstOrDefault();
Status = Cameras.Count == 0 ? "Aucune camera Linux detectee. Branchez une camera puis actualisez."
: "Selectionnez le pair, l'adresse LAN/VPN et les peripheriques. Utilisez un casque.";
}
catch (Exception error) { Status = "Peripheriques indisponibles : " + error.Message; }
}
private void ValidateSelection()
{
if (!AllowAddressDisclosure)
throw new InvalidOperationException("Confirmez le partage de votre adresse LAN/VPN 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.IsPrivateIPv4(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;
_activeMicrophone = Microphone.Value;
_activeAddress = Address;
_activePort = port;
}
[RelayCommand]
private Task InviteAsync() => RunAsync(async () =>
{
if (Busy) throw new InvalidOperationException("Un appel est deja en cours.");
ValidateSelection();
if (string.IsNullOrWhiteSpace(Target) || string.IsNullOrWhiteSpace(Room))
throw new InvalidOperationException("Selectionnez une salle jointe et un autre utilisateur.");
Busy = true;
_call = await _signaling.InviteLanAsync(Room, Target.Trim(), _lifetime.Token);
Status = $"Invitation envoyee a {_call.Callee} ; attente du consentement.";
});
[RelayCommand]
private Task AcceptAsync() => RunAsync(async () =>
{
if (_call == null || !Incoming)
throw new InvalidOperationException("Aucune invitation a accepter.");
ValidateSelection();
Incoming = false;
_accepting = true;
await _signaling.AcceptAsync(_call.Id, _lifetime.Token);
Status = "Invitation acceptee ; preparation des medias.";
});
[RelayCommand]
public Task HangupAsync()
{
_callLifetime?.Cancel();
return RunAsync(() => StopAsync("Appel termine.", true));
}
private void OnInvitation(CallSession call) => Queue(async () =>
{
if (call.Transport != CallTransports.LanSrtp)
{
await _signaling.EndAsync(call.Id, _lifetime.Token);
Status = "Invitation WebRTC non prise en charge par le mode Linux LAN.";
return;
}
if (_call != null) throw new InvalidOperationException("Invitation concurrente inattendue.");
if (call.RelayOnly) throw new InvalidOperationException("Le mode LAN ne peut pas masquer les adresses IP.");
_call = call;
Busy = true;
Incoming = true;
Status = $"{call.Caller} propose un appel SRTP dans {call.RoomName}. Acceptez ou refusez.";
await Task.CompletedTask;
});
private void OnAccepted(CallAccepted accepted) => Queue(async () =>
{
if (_call?.Id != accepted.Session.Id) return;
if (_media != null) throw new InvalidOperationException("Acceptation repetee.");
if (accepted.Session.Transport != CallTransports.LanSrtp || accepted.Session.RelayOnly ||
(Incoming && !_accepting))
throw new InvalidOperationException("Acceptation ou politique de transport inattendue.");
_call = accepted.Session;
Incoming = false;
_accepting = false;
_callLifetime = CancellationTokenSource.CreateLinkedTokenSource(_lifetime.Token);
_media = _createMedia(_activeAddress!, _activePort);
LocalVideo = _media.LocalVideo;
RemoteVideo = _media.RemoteVideo;
Status = "Connexion SRTP : attente du pair (30 secondes maximum).";
await _signaling.SendAsync(_call.Id, new("rtp-endpoint", Endpoint: _media.Endpoint), _callLifetime.Token);
_ = MonitorAsync(_call.Id, _media, _callLifetime.Token);
});
private void OnSignal(Guid id, CallSignal signal) => Queue(async () =>
{
if (_call?.Id != id) return;
if (_media == null || _callLifetime == null || signal.Kind != "rtp-endpoint" ||
signal.Endpoint?.IsValid() != true || _endpointReceived)
throw new InvalidOperationException("Destination SRTP manquante, invalide ou repetee.");
_endpointReceived = true;
await _media.StartSendingAsync(signal.Endpoint, _activeCamera!, _activeMicrophone!, _callLifetime.Token);
Status = "Emission SRTP demarree ; verification de la reception audio-video.";
});
private void OnEnded(CallEnded ended) => QueueStop(ended.CallId, async () =>
{
if (_call?.Id == ended.CallId)
await StopAsync("Appel termine : " + ended.Reason, false);
});
private void OnInterrupted() => QueueStop(null,
() => StopAsync("Connexion SignalR interrompue ; medias arretes.", false));
private void QueueStop(Guid? id, Func<Task> action) => Dispatcher.UIThread.Post(async () =>
{
if (id == null || _call?.Id == id) _callLifetime?.Cancel();
await RunAsync(action);
});
private async Task MonitorAsync(Guid id, ILanCallMedia media, CancellationToken ct)
{
var start = Stopwatch.StartNew();
var received = false;
try
{
while (!ct.IsCancellationRequested)
{
if (media.Completion.IsCompleted) await media.Completion;
if (!received && media.HasRemoteMedia)
{
received = true;
await Dispatcher.UIThread.InvokeAsync(() =>
{
if (_call?.Id == id) Status = "Reception audio-video SRTP etablie (LAN/VPN).";
});
}
if (!received && start.Elapsed > TimeSpan.FromSeconds(30))
throw new TimeoutException("Aucun media distant : verifiez adresses, port UDP et pare-feu LAN/VPN.");
await Task.Delay(500, ct);
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { }
catch (Exception error)
{
Queue(() => _call?.Id == id && ReferenceEquals(_media, media)
? FailAsync(error) : Task.CompletedTask);
}
}
private void Queue(Func<Task> action) =>
Dispatcher.UIThread.Post(async () => await RunAsync(action));
private async Task RunAsync(Func<Task> action)
{
await _events.WaitAsync();
try
{
if (_disposed) return;
Preparing = true;
await action();
}
catch (OperationCanceledException) when (
_lifetime.IsCancellationRequested || _callLifetime?.IsCancellationRequested == true) { }
catch (Exception error) { await FailAsync(error); }
finally
{
Preparing = false;
_events.Release();
}
}
private async Task FailAsync(Exception error)
{
try { await StopAsync("Echec de l'appel : " + error.Message, true); }
catch (Exception cleanupError)
{
Status = $"Echec de l'appel : {error.Message}. Nettoyage/signalisation : {cleanupError.Message}";
}
}
private async Task StopAsync(string status, bool notify)
{
var call = _call;
_call = null;
Incoming = false;
Busy = false;
_accepting = false;
_endpointReceived = false;
_callLifetime?.Cancel();
var media = _media;
_media = null;
try
{
if (media != null) await media.DisposeAsync();
}
finally
{
LocalVideo = null;
RemoteVideo = null;
_callLifetime?.Dispose();
_callLifetime = null;
Status = status;
}
if (notify && call != null)
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await _signaling.EndAsync(call.Id, timeout.Token);
}
}
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_lifetime.Cancel();
await _events.WaitAsync();
try
{
if (_disposed) return;
_disposed = true;
_signaling.Invitation -= OnInvitation;
_signaling.Accepted -= OnAccepted;
_signaling.Signal -= OnSignal;
_signaling.Ended -= OnEnded;
_signaling.Interrupted -= OnInterrupted;
try { await StopAsync("Appel arrete.", true); }
finally { _lifetime.Dispose(); }
}
finally { _events.Release(); }
}
}

View file

@ -10,13 +10,14 @@
xmlns:postitControls="using:PostIt.Controls"
xmlns:views="using:PostIt.Views.Chat"
xmlns:vm="using:PostIt.ViewModels.Chat"
xmlns:services="using:PostIt.Services"
x:DataType="vm:ChatViewModel"
>
<Design.DataContext>
<vm:ChatViewModel />
</Design.DataContext>
<Grid Margin="10" RowDefinitions="Auto, *, Auto, Auto">
<Grid Margin="10" RowDefinitions="Auto, *, Auto, Auto, Auto">
<Grid
Grid.Row="1"
@ -102,9 +103,52 @@
Content="Envoyer" />
</Grid>
<Expander Grid.Row="3" Header="Appel Linux direct (LAN/VPN)" IsVisible="{Binding SupportsLanCalls}" IsExpanded="True"
IsEnabled="{Binding IsConnected}" Margin="0,0,0,8">
<StackPanel DataContext="{Binding LanCall}" x:DataType="vm:LanCallViewModel" Spacing="8">
<TextBlock Text="Deux Desktop Linux sur LAN/VPN. SRTP chiffre les medias ; le pair connait votre IP. Sans TURN ni traversee NAT. Casque recommande (pas d'annulation d'echo)."
TextWrapping="Wrap" />
<WrapPanel IsEnabled="{Binding !Busy}">
<Button Content="Actualiser les peripheriques" Command="{Binding RefreshDevicesCommand}" Margin="0,0,8,4" />
<ComboBox Width="160" ItemsSource="{Binding Addresses}" SelectedItem="{Binding Address}"
PlaceholderText="Adresse locale LAN/VPN" 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">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="services:CaptureDevice">
<TextBlock Text="{Binding Name}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<ComboBox Width="190" ItemsSource="{Binding Microphones}" SelectedItem="{Binding Microphone}"
PlaceholderText="Microphone" Margin="0,0,8,4">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="services:CaptureDevice">
<TextBlock Text="{Binding Name}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</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 LAN/VPN avec le pair."
IsChecked="{Binding AllowAddressDisclosure}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Content="Appeler dans la salle active" Command="{Binding InviteCommand}" IsEnabled="{Binding !Busy}" />
<Button Content="Accepter" Command="{Binding AcceptCommand}" IsEnabled="{Binding Incoming}" />
<Button Content="Refuser / Raccrocher" Command="{Binding HangupCommand}" IsEnabled="{Binding Busy}" />
</StackPanel>
<TextBlock Text="{Binding Status}" TextWrapping="Wrap" />
<Grid ColumnDefinitions="*,2*" Height="280" IsVisible="{Binding !!RemoteVideo}">
<ContentControl Content="{Binding LocalVideo}" Margin="0,0,8,0" />
<ContentControl Grid.Column="1" Content="{Binding RemoteVideo}" />
</Grid>
</StackPanel>
</Expander>
<postitControls:StatusBar
x:Name="StatusBar"
Grid.Row="3"
Grid.Row="4"
DataContext="{Binding Status}" />
</Grid>

View file

@ -1,4 +1,6 @@
using Avalonia;
using Avalonia.Controls;
using PostIt.ViewModels.Chat;
namespace PostIt.Views.Chat;
public partial class ChatPage : ContentPage
{
@ -8,4 +10,10 @@ public partial class ChatPage : ContentPage
InitializeComponent();
}
protected override async void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
if (DataContext is ChatViewModel { LanCall: { } call })
await call.HangupAsync();
}
}

View file

@ -5,6 +5,7 @@ namespace Yavsc.Abstract.Chat.Calls;
public static class CallHubMethods
{
public const string Invite = "InviteCall";
public const string InviteLan = "InviteLanCall";
public const string Accept = "AcceptCall";
public const string End = "EndCall";
public const string Signal = "SendCallSignal";
@ -16,7 +17,13 @@ public static class CallHubMethods
public sealed record CallSession(
Guid Id, string RoomName, string Caller, string Callee, bool RelayOnly,
DateTimeOffset ExpiresAt);
DateTimeOffset ExpiresAt, string Transport = CallTransports.WebRtc);
public static class CallTransports
{
public const string WebRtc = "webrtc";
public const string LanSrtp = "lan-srtp";
}
public sealed record CallIceServer(string[] Urls, string? Username = null, string? Credential = null);
@ -26,4 +33,29 @@ public sealed record CallEnded(Guid CallId, string Reason);
public sealed record CallSignal(
string Kind, string? Sdp = null, string? Candidate = null,
string? SdpMid = null, int? SdpMLineIndex = null);
string? SdpMid = null, int? SdpMLineIndex = null, LanCallEndpoint? Endpoint = null);
public sealed record LanCallEndpoint(string Address, int Port, string Key, string Salt)
{
public override string ToString() => "SRTP endpoint (credentials redacted)";
public bool IsValid() =>
LanCallNetwork.IsPrivateIPv4(Address) && Port is >= 1024 and <= 65534 && Port % 2 == 0 &&
Key is { Length: 32 } && Key.All(Uri.IsHexDigit) &&
Salt is { Length: 28 } && Salt.All(Uri.IsHexDigit);
}
public static class LanCallNetwork
{
public static bool IsPrivateIPv4(string address)
{
if (!System.Net.IPAddress.TryParse(address, out var ip) ||
ip.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork ||
ip.ToString() != address)
return false;
var bytes = ip.GetAddressBytes();
return bytes[0] == 10 || bytes[0] == 127 ||
(bytes[0] == 172 && bytes[1] is >= 16 and <= 31) ||
(bytes[0] == 192 && bytes[1] == 168);
}
}

View file

@ -9,22 +9,30 @@ namespace Yavsc.Server.Hubs;
public partial class ChatHub
{
public async Task<CallSession> InviteCall(string roomName, string callee)
public Task<CallSession> InviteCall(string roomName, string callee) =>
InviteCoreAsync(roomName, callee, CallTransports.WebRtc);
public Task<CallSession> InviteLanCall(string roomName, string callee) =>
InviteCoreAsync(roomName, callee, CallTransports.LanSrtp);
private async Task<CallSession> InviteCoreAsync(string roomName, string callee, string transport)
{
var caller = CallUser();
RequireSecureLanControl(transport);
if (string.IsNullOrWhiteSpace(roomName) || !InputValidator.ValidateRoomName(roomName) ||
string.IsNullOrWhiteSpace(callee) || !InputValidator.ValidateUserName(callee))
throw new HubException("Invalid call participant or room.");
RequireCallPresence(roomName, caller, callee);
var relayOnly = await RequiresRelayAsync(caller, callee);
return await _calls.InviteAsync(roomName, caller, callee, Context.ConnectionId,
_cxManager.GetConnexionIds(callee)?.Distinct().ToArray() ?? [], relayOnly);
_cxManager.GetConnexionIds(callee)?.Distinct().ToArray() ?? [], relayOnly, transport);
}
public async Task AcceptCall(Guid callId)
{
var user = CallUser();
var session = await _calls.GetSessionAsync(callId, user, Context.ConnectionId);
RequireSecureLanControl(session.Transport);
RequireCallPresence(session.RoomName, session.Caller, session.Callee);
await _calls.AcceptAsync(callId, user, Context.ConnectionId,
await RequiresRelayAsync(session.Caller, session.Callee));
@ -36,6 +44,7 @@ public partial class ChatHub
{
var user = CallUser();
var session = await _calls.GetSessionAsync(callId, user, Context.ConnectionId);
RequireSecureLanControl(session.Transport);
RequireCallPresence(session.RoomName, session.Caller, session.Callee);
if (!session.RelayOnly && await RequiresRelayAsync(session.Caller, session.Callee))
{
@ -53,6 +62,12 @@ public partial class ChatHub
return user;
}
private void RequireSecureLanControl(string transport)
{
if (transport == CallTransports.LanSrtp && Context.GetHttpContext()?.Request.IsHttps != true)
throw new HubException("LAN calls require HTTPS signaling to protect SRTP keys.");
}
private void RequireCallPresence(string room, string caller, string callee)
{
if (!_cxManager.IsConnected(caller) || !_cxManager.IsConnected(callee) ||

View file

@ -12,6 +12,8 @@ public static class CallSignalValidator
{
if (signal is null)
throw new HubException("A call signal is required.");
if (signal.Endpoint != null)
throw new HubException("WebRTC signaling cannot carry a LAN endpoint.");
if (signal.Kind is "offer" or "answer")
{
if (string.IsNullOrWhiteSpace(signal.Sdp) || Encoding.UTF8.GetByteCount(signal.Sdp) > 24000 ||

View file

@ -27,6 +27,7 @@ public sealed class CallSignalingService(
public bool Offered { get; set; }
public bool Answered { get; set; }
public int CandidateCount { get; set; }
public HashSet<string> EndpointConnections { get; } = [];
public string[] Connections => [CallerConnection, .. CalleeConnections];
}
@ -36,13 +37,13 @@ public sealed class CallSignalingService(
public async Task<CallSession> InviteAsync(
string room, string caller, string callee, string callerConnection,
string[] calleeConnections, bool relayOnly)
string[] calleeConnections, bool relayOnly, string transport = CallTransports.WebRtc)
{
EnsureEnabled();
if (caller == callee || calleeConnections.Length == 0 ||
calleeConnections.Contains(callerConnection))
throw new HubException("Call participants are unavailable.");
ValidateIceConfiguration(relayOnly);
ValidateTransport(transport, relayOnly);
await _gate.WaitAsync();
try
{
@ -52,7 +53,7 @@ public sealed class CallSignalingService(
c.Session.Caller == callee || c.Session.Callee == callee))
throw new HubException("A participant is already in a call.");
var session = new CallSession(Guid.NewGuid(), room, caller, callee, relayOnly,
clock.GetUtcNow().AddSeconds(45));
clock.GetUtcNow().AddSeconds(45), transport);
var call = new PendingCall(session, callerConnection, calleeConnections);
_calls.Add(session.Id, call);
try
@ -83,7 +84,7 @@ public sealed class CallSignalingService(
if (call.AcceptedConnection != null)
throw new HubException("This call was already accepted.");
var requireRelay = call.Session.RelayOnly || relayOnly;
ValidateIceConfiguration(requireRelay);
ValidateTransport(call.Session.Transport, requireRelay);
call.AcceptedConnection = connection;
call.Session = call.Session with
{
@ -92,7 +93,8 @@ public sealed class CallSignalingService(
};
var otherDevices = call.CalleeConnections.Where(c => c != connection).ToArray();
call.CalleeConnections.IntersectWith([connection]);
var accepted = new CallAccepted(call.Session, CreateIceServers(call.Session));
var accepted = new CallAccepted(call.Session, call.Session.Transport == CallTransports.LanSrtp
? [] : CreateIceServers(call.Session));
try
{
if (otherDevices.Length > 0)
@ -122,6 +124,18 @@ public sealed class CallSignalingService(
var callee = call.AcceptedConnection == connection && call.Session.Callee == user;
if ((!caller && !callee) || call.AcceptedConnection == null)
throw new HubException("Media signaling requires acceptance on this device.");
if (call.Session.Transport == CallTransports.LanSrtp)
{
ValidateTransport(call.Session.Transport, call.Session.RelayOnly);
if (signal is null || signal.Kind != "rtp-endpoint" || signal.Endpoint?.IsValid() != true ||
signal.Sdp != null || signal.Candidate != null || signal.SdpMid != null ||
signal.SdpMLineIndex != null || call.EndpointConnections.Contains(connection))
throw new HubException("A LAN call requires one valid SRTP endpoint per accepted device.");
var endpointTarget = caller ? call.AcceptedConnection : call.CallerConnection;
await hub.Clients.Client(endpointTarget).SendAsync(CallHubMethods.SignalReceived, id, signal);
call.EndpointConnections.Add(connection);
return;
}
CallSignalValidator.Validate(signal, call.Session.RelayOnly);
switch (signal.Kind)
{
@ -252,6 +266,19 @@ public sealed class CallSignalingService(
throw new HubException("ICE/TURN configuration is unavailable. No direct fallback is allowed.");
}
private void ValidateTransport(string transport, bool relayOnly)
{
if (transport == CallTransports.LanSrtp)
{
if (!_options.EnableLanCalls || relayOnly)
throw new HubException("LAN calls must be enabled and cannot hide participant IP addresses.");
}
else if (transport == CallTransports.WebRtc)
ValidateIceConfiguration(relayOnly);
else
throw new HubException("Unsupported call transport.");
}
private static bool ValidUrl(string url, params string[] schemes)
{
if (string.IsNullOrWhiteSpace(url) || url.Length > 512 || url.Any(char.IsWhiteSpace))

View file

@ -7,6 +7,7 @@ public sealed class AudioVideoCallOptions
public const string SectionName = "AudioVideoCalls";
public bool Enabled { get; set; }
public bool EnableLanCalls { get; set; }
public string[] StunUrls { get; set; } = [];
public string[] TurnUrls { get; set; } = [];
public string? TurnSharedSecret { get; set; }

View file

@ -1,4 +1,6 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Connections.Features;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
@ -32,7 +34,8 @@ public sealed class CallHubTests
_services = new ServiceCollection().AddLogging().AddLocalization().BuildServiceProvider();
Calls = new(Delivery, Options.Create(new AudioVideoCallOptions
{
Enabled = true, TurnUrls = ["turn:turn.example.test:3478"], TurnSharedSecret = "test-only"
Enabled = true, EnableLanCalls = true,
TurnUrls = ["turn:turn.example.test:3478"], TurnSharedSecret = "test-only"
}), TimeProvider.System, NullLogger<CallSignalingService>.Instance);
Db.Users.AddRange(
new ApplicationUser { Id = "alice-id", UserName = "alice", Email = "alice@example.test" },
@ -55,6 +58,36 @@ public sealed class CallHubTests
}
}
[Fact]
public async Task Lan_requires_https_at_invitation_acceptance_and_key_exchange()
{
using var f = new Fixture();
using var caller = f.Hub("alice", "alice-1");
using var callee = f.Hub("bob", "bob-1");
await Assert.ThrowsAsync<HubException>(() => caller.InviteLanCall("General", "bob"));
Assert.Empty(f.Delivery.Messages);
var http = new DefaultHttpContext();
http.Request.Scheme = "https";
caller.Context.Features.Set<IHttpContextFeature>(new HttpFeature(http));
var call = await caller.InviteLanCall("General", "bob");
await Assert.ThrowsAsync<HubException>(() => callee.AcceptCall(call.Id));
callee.Context.Features.Set<IHttpContextFeature>(new HttpFeature(http));
await callee.AcceptCall(call.Id);
var endpoint = new CallSignal("rtp-endpoint",
Endpoint: new("192.168.1.2", 5004, new string('a', 32), new string('b', 28)));
http.Request.Scheme = "http";
await Assert.ThrowsAsync<HubException>(() => caller.SendCallSignal(call.Id, endpoint));
Assert.DoesNotContain(f.Delivery.Messages, m => m.Method == CallHubMethods.SignalReceived);
http.Request.Scheme = "https";
await caller.SendCallSignal(call.Id, endpoint);
Assert.Single(f.Delivery.Messages, m => m.Method == CallHubMethods.SignalReceived);
}
private sealed class HttpFeature(HttpContext context) : IHttpContextFeature
{
public HttpContext? HttpContext { get; set; } = context;
}
[Fact]
public async Task Invitation_requires_existing_accounts_presence_and_the_authenticated_connection()
{

View file

@ -0,0 +1,116 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Yavsc.Abstract.Chat.Calls;
using Yavsc.Services;
using Yavsc.Settings;
namespace Yavsc.Server.Tests;
public sealed class CallLanSignalingTests
{
private static LanCallEndpoint Endpoint(string address = "192.168.1.2", int port = 5004) =>
new(address, port, new string('a', 32), new string('b', 28));
private static CallSignalingService Service(RecordingCallHub hub, bool enabled = true) =>
new(hub, Options.Create(new AudioVideoCallOptions { Enabled = true, EnableLanCalls = enabled }),
TimeProvider.System, NullLogger<CallSignalingService>.Instance);
[Theory]
[InlineData(false, false)]
[InlineData(true, true)]
public async Task Disabled_lan_or_ip_masking_prevents_invitation(bool enabled, bool relay)
{
var hub = new RecordingCallHub();
using var service = Service(hub, enabled);
await Assert.ThrowsAsync<HubException>(() =>
service.InviteAsync("General", "alice", "bob", "a", ["b"], relay, CallTransports.LanSrtp));
Assert.Empty(hub.Messages);
}
[Fact]
public async Task Ip_masking_activated_before_acceptance_blocks_the_lan_call()
{
var hub = new RecordingCallHub();
using var service = Service(hub);
var call = await service.InviteAsync("General", "alice", "bob", "a", ["b"], false, CallTransports.LanSrtp);
await Assert.ThrowsAsync<HubException>(() => service.AcceptAsync(call.Id, "bob", "b", true));
Assert.DoesNotContain(hub.Messages, m => m.Method == CallHubMethods.Accepted);
}
[Fact]
public async Task Endpoints_are_exchanged_only_once_between_consented_devices_without_ice_credentials()
{
var hub = new RecordingCallHub();
using var service = Service(hub);
var call = await service.InviteAsync("General", "alice", "bob", "a", ["b", "other"], false, CallTransports.LanSrtp);
Assert.Equal(CallTransports.LanSrtp, call.Transport);
var first = new CallSignal("rtp-endpoint", Endpoint: Endpoint());
var second = new CallSignal("rtp-endpoint", Endpoint: Endpoint("10.0.0.2"));
await Assert.ThrowsAsync<HubException>(() => service.SignalAsync(call.Id, "alice", "a", first));
await service.AcceptAsync(call.Id, "bob", "b", false);
Assert.Empty(Assert.IsType<CallAccepted>(hub.Messages.Last().Arguments[0]).IceServers);
await Assert.ThrowsAsync<HubException>(() => service.SignalAsync(call.Id, "bob", "other", second));
await service.SignalAsync(call.Id, "alice", "a", first);
await service.SignalAsync(call.Id, "bob", "b", second);
var sent = hub.Messages.Where(m => m.Method == CallHubMethods.SignalReceived).ToArray();
Assert.Equal(2, sent.Length);
Assert.Equal(["b"], sent[0].Targets);
Assert.Equal(["a"], sent[1].Targets);
await Assert.ThrowsAsync<HubException>(() => service.SignalAsync(call.Id, "alice", "a", first));
await service.EndAsync(call.Id, "alice", "a");
await Assert.ThrowsAsync<HubException>(() => service.SignalAsync(call.Id, "bob", "b", second));
}
[Fact]
public async Task Lan_transport_rejects_sdp_candidates_and_mixed_endpoint_payloads()
{
var hub = new RecordingCallHub();
using var service = Service(hub);
var call = await service.InviteAsync("General", "alice", "bob", "a", ["b"], false, CallTransports.LanSrtp);
await service.AcceptAsync(call.Id, "bob", "b", false);
CallSignal[] invalid =
[
new("offer", "v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\n"),
new("candidate", Candidate: "candidate:1 1 udp 100 192.168.1.1 5004 typ host"),
new("rtp-endpoint"), new("rtp-endpoint", Sdp: "injected", Endpoint: Endpoint()),
new("rtp-endpoint", Endpoint: Endpoint() with { Key = new string('x', 32) }),
new("rtp-endpoint", Endpoint: Endpoint() with { Salt = "short" }),
new("rtp-endpoint", Endpoint: Endpoint("8.8.8.8")),
new("rtp-endpoint", Endpoint: Endpoint(port: 5005))
];
foreach (var signal in invalid)
await Assert.ThrowsAsync<HubException>(() => service.SignalAsync(call.Id, "alice", "a", signal));
Assert.DoesNotContain(hub.Messages, m => m.Method == CallHubMethods.SignalReceived);
Assert.Throws<HubException>(() => CallSignalValidator.Validate(
new("offer", "v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 111\r\n", Endpoint: Endpoint()), false));
}
[Theory]
[InlineData("10.1.2.3", true)]
[InlineData("172.16.0.1", true)]
[InlineData("172.31.255.254", true)]
[InlineData("192.168.1.2", true)]
[InlineData("127.0.0.1", true)]
[InlineData("172.32.0.1", false)]
[InlineData("169.254.169.254", false)]
[InlineData("8.8.8.8", false)]
[InlineData("224.0.0.1", false)]
[InlineData("0.0.0.0", false)]
[InlineData("::1", false)]
[InlineData("localhost", false)]
[InlineData("127.1", false)]
[InlineData("192.168.1.2,port=80", false)]
public void Only_canonical_private_or_loopback_ipv4_endpoints_are_allowed(string address, bool expected) =>
Assert.Equal(expected, Endpoint(address).IsValid());
[Fact]
public void Credentials_are_not_exposed_by_record_diagnostics()
{
var endpoint = Endpoint();
var diagnostic = new CallSignal("rtp-endpoint", Endpoint: endpoint).ToString();
Assert.DoesNotContain(endpoint.Key, diagnostic);
Assert.DoesNotContain(endpoint.Salt, diagnostic);
Assert.DoesNotContain(endpoint.Address, diagnostic);
}
}