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:
parent
5e48831d6d
commit
cc28ee8b29
20 changed files with 1394 additions and 29 deletions
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
192
src/PostIt/PostIt.Tests/LanCallViewModelTests.cs
Normal file
192
src/PostIt/PostIt.Tests/LanCallViewModelTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
113
src/PostIt/PostIt.Tests/LinuxLanCallMediaTests.cs
Normal file
113
src/PostIt/PostIt.Tests/LinuxLanCallMediaTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue